From d7c910fcecdac1821c510767751f0de0192716f7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 11:39:16 +0200 Subject: [PATCH 01/28] chore: bump frontend and cli python parser wasm packages to 1.830.0 (#11720) * chore: bump frontend python parser wasm packages to 1.830.0 Co-Authored-By: Claude Opus 5.5 * chore: bump cli python parser wasm packages to 1.830.0 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../parsers/windmill-parser-wasm/Cargo.lock | 166 +++++++++++------- cli/bun.lock | 12 +- cli/package.json | 6 +- frontend/package-lock.json | 24 +-- frontend/package.json | 6 +- 5 files changed, 131 insertions(+), 83 deletions(-) diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 75c10528df..51878c6dfb 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -2,21 +2,22 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.7.5" @@ -24,11 +25,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" dependencies = [ "cfg-if", - "cipher", + "cipher 0.3.0", "cpufeatures 0.2.17", "opaque-debug", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" @@ -389,21 +415,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.21.7" @@ -539,7 +550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cb03d1bed155d89dce0f845b7899b18a9a163e148fd004e1c28421a783e2d8e" dependencies = [ "block-padding", - "cipher", + "cipher 0.3.0", ] [[package]] @@ -763,6 +774,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1095,6 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1107,6 +1129,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1163,7 +1194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac41dd49fb554432020d52c875fc290e110113f864c6b1b525cd62c7e7747a5d" dependencies = [ "byteorder", - "cipher", + "cipher 0.3.0", "opaque-debug", ] @@ -1636,10 +1667,14 @@ dependencies = [ ] [[package]] -name = "gimli" -version = "0.32.3" +name = "ghash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] [[package]] name = "git-version" @@ -2101,6 +2136,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -2116,17 +2160,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "io-uring" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -2455,7 +2488,7 @@ version = "3.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c42f95f9d296f2dcb50665f507ed5a68a171453142663ce44d77a4eb217b053" dependencies = [ - "aes", + "aes 0.7.5", "base64 0.21.7", "block-modes", "crc-any", @@ -2619,9 +2652,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -3220,6 +3253,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "postgres-native-tls" version = "0.5.1" @@ -4031,12 +4076,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -5350,34 +5389,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.7", ] [[package]] @@ -5875,6 +5911,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unscanny" version = "0.1.0" @@ -6296,6 +6342,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" name = "windmill-common" version = "1.830.0" dependencies = [ + "aes-gcm", "aho-corasick", "anyhow", "arc-swap", @@ -6323,6 +6370,7 @@ dependencies = [ "git-version", "globset", "hex", + "hkdf", "hmac 0.12.1", "hyper", "indexmap", diff --git a/cli/bun.lock b/cli/bun.lock index 0e0885f563..db6fd253bf 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -19,14 +19,14 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.56.8", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", - "windmill-parser-wasm-py-imports": "1.693.1", + "windmill-parser-wasm-py": "1.830.0", + "windmill-parser-wasm-py-imports": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", @@ -291,7 +291,7 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.753.0", "", {}, "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg=="], + "windmill-parser-wasm-asset": ["windmill-parser-wasm-asset@1.830.0", "", {}, "sha512-q4HSaMOd5cF8pmfPcEGiGeQbO8vhtv89us7zbO12IlgpxpWT8ojvw66J75y4yUt1PaEuaoztSaX8Gkxxn/6MXw=="], "windmill-parser-wasm-csharp": ["windmill-parser-wasm-csharp@1.510.1", "", {}, "sha512-qm09YmnbeYHLwYn1jUnObVzPhYO9NZKMlIO7nlo7zPJBXqksgG5fK/KCtwGw9rChrnz+DsvM9wP5FhrwRLMtwQ=="], @@ -303,9 +303,9 @@ "windmill-parser-wasm-php": ["windmill-parser-wasm-php@1.647.1", "", {}, "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg=="], - "windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.782.0", "", {}, "sha512-ZnpEvhbffaJI+Uq7HH1mVO6kUgR4uiAIIxaLa30Pqb1/oz0h4abT6W7bFOApIE5X9SDOuycoaDBEr9Ogm6ZLzQ=="], + "windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.830.0", "", {}, "sha512-+P4mIyp3lXkgjsr3/20Oc31ank8orQDd4K/v8TnjTQnCyxyy+VB9rynLESnkH6OlcEVSk2qf9pDM9c367E8DoQ=="], - "windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.693.1", "", {}, "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g=="], + "windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.830.0", "", {}, "sha512-Pi9gHStNFTEqfcDrjSxK5SnI/1dTG4xKcVUD3BHlR1aQ7E/VxkTD/D0INJm7mNC2usKb2bO21NMdKmGh+RAXEw=="], "windmill-parser-wasm-r": ["windmill-parser-wasm-r@1.668.1", "", {}, "sha512-5YNeUibxpNBvYrxCgQcz1PxGhTFx2CyEpg2udtIhq7bx0d4gF/KDZVupMeQmAObmrEtTSFGUWNRJ4zXSWNrSpQ=="], diff --git a/cli/package.json b/cli/package.json index 5e7623a7ab..5efe8a2499 100644 --- a/cli/package.json +++ b/cli/package.json @@ -31,14 +31,14 @@ "pg-gateway": "0.3.0-beta.4", "svelte": "^5.56.8", "tar-stream": "^3.1.7", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", - "windmill-parser-wasm-py-imports": "1.693.1", + "windmill-parser-wasm-py": "1.830.0", + "windmill-parser-wasm-py-imports": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c8250f9eca..6130364ff9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -95,19 +95,19 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", + "windmill-parser-wasm-py": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.695.0", - "windmill-parser-wasm-wac": "1.668.6", + "windmill-parser-wasm-wac": "1.830.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "1.8.2", @@ -14644,9 +14644,9 @@ } }, "node_modules/windmill-parser-wasm-asset": { - "version": "1.753.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.753.0.tgz", - "integrity": "sha512-zpJhjvcU8EWRoOJzas/nRGKjGdQnvzeB9GOxP+Mdmnk8BFk3uehsmHS2Krxyjo36fUrCHqobbnffEqf0g3LIGg==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.830.0.tgz", + "integrity": "sha512-q4HSaMOd5cF8pmfPcEGiGeQbO8vhtv89us7zbO12IlgpxpWT8ojvw66J75y4yUt1PaEuaoztSaX8Gkxxn/6MXw==" }, "node_modules/windmill-parser-wasm-csharp": { "version": "1.510.1", @@ -14674,9 +14674,9 @@ "integrity": "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg==" }, "node_modules/windmill-parser-wasm-py": { - "version": "1.782.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.782.0.tgz", - "integrity": "sha512-ZnpEvhbffaJI+Uq7HH1mVO6kUgR4uiAIIxaLa30Pqb1/oz0h4abT6W7bFOApIE5X9SDOuycoaDBEr9Ogm6ZLzQ==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.830.0.tgz", + "integrity": "sha512-+P4mIyp3lXkgjsr3/20Oc31ank8orQDd4K/v8TnjTQnCyxyy+VB9rynLESnkH6OlcEVSk2qf9pDM9c367E8DoQ==" }, "node_modules/windmill-parser-wasm-r": { "version": "1.668.1", @@ -14704,9 +14704,9 @@ "integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw==" }, "node_modules/windmill-parser-wasm-wac": { - "version": "1.668.6", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.668.6.tgz", - "integrity": "sha512-/ovcLWlIO+TQMrnWwcWoXquJI6hTZ+Zpo4POhV8z6e+pb4cVModGysASI9mERn2e9uj8/xSRmYqk54TLG8oUbQ==" + "version": "1.830.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.830.0.tgz", + "integrity": "sha512-M67qPkzDnuMNWGrZTj9V4ajDoCQrTsQJ89UMHq308t5CtCdtiX8Z3freL+8SoRAG/dVlqQ8roK6dzncHIEM0hQ==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.770.0", diff --git a/frontend/package.json b/frontend/package.json index ddfc6b410b..7522af8675 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -161,19 +161,19 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", - "windmill-parser-wasm-asset": "1.753.0", + "windmill-parser-wasm-asset": "1.830.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.761.0", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.782.0", + "windmill-parser-wasm-py": "1.830.0", "windmill-parser-wasm-r": "1.668.1", "windmill-parser-wasm-regex": "1.764.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.695.0", - "windmill-parser-wasm-wac": "1.668.6", + "windmill-parser-wasm-wac": "1.830.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "1.8.2", From a80c6ffacb24de078260d53919d99648fd6fd859 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 12:14:49 +0200 Subject: [PATCH 02/28] refactor: mount the run page in process in ai session previews (#11721) * refactor: mount the run page in process in ai session previews Co-Authored-By: Claude Opus 5.5 * fix: let run again fire repeatedly and re-pin an open script tab Co-Authored-By: Claude Opus 5.5 * fix: keep a run opened through a share link in its own frame Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../components/MemoryFootprintViewer.svelte | 6 +- .../src/lib/components/runs/RunDetail.svelte | 1450 +++++++++++++++++ .../components/runs/ScriptRetryChain.svelte | 11 +- .../components/sessions/ItemViewerView.svelte | 22 +- .../components/sessions/PreviewTabHost.svelte | 33 + .../components/sessions/RunViewerView.svelte | 106 ++ .../components/sessions/previewRouter.test.ts | 12 + .../lib/components/sessions/previewRouter.ts | 33 +- .../sessions/sessionRuntime.svelte.ts | 5 +- .../(root)/(logged)/run/[...run]/+page.svelte | 1376 +--------------- 10 files changed, 1673 insertions(+), 1381 deletions(-) create mode 100644 frontend/src/lib/components/runs/RunDetail.svelte create mode 100644 frontend/src/lib/components/sessions/RunViewerView.svelte diff --git a/frontend/src/lib/components/MemoryFootprintViewer.svelte b/frontend/src/lib/components/MemoryFootprintViewer.svelte index 9547861bc3..14ad1a2b06 100644 --- a/frontend/src/lib/components/MemoryFootprintViewer.svelte +++ b/frontend/src/lib/components/MemoryFootprintViewer.svelte @@ -3,7 +3,7 @@ import { type MetricDataPoint, MetricsService } from '$lib/gen' import { displayTime } from '$lib/utils' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from './operatingWorkspace.svelte' import { CategoryScale, Chart as ChartJS, @@ -26,6 +26,8 @@ let { jobId, jobUpdateLastFetch }: Props = $props(); + const operatingWorkspace = useOperatingWorkspace() + let jobMetricsLastFetch: Date | undefined = undefined let jobMemoryStats: MetricDataPoint[] | undefined = $state(undefined) @@ -38,7 +40,7 @@ async function loadMetricsData() { try { let jobStatsPromise = MetricsService.getJobMetrics({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, id: jobId, requestBody: { from_timestamp: jobMetricsLastFetch?.toISOString(), diff --git a/frontend/src/lib/components/runs/RunDetail.svelte b/frontend/src/lib/components/runs/RunDetail.svelte new file mode 100644 index 0000000000..1241bc9c60 --- /dev/null +++ b/frontend/src/lib/components/runs/RunDetail.svelte @@ -0,0 +1,1450 @@ + + + + + + +{#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} + + + {#snippet actions()} +
+
+ +
+ +
+ {/snippet} +

+					
+			
+
+
+{/if} +{#if !job || (job?.job_kind != 'flow' && job?.job_kind != 'flownode' && job?.job_kind != 'flowpreview')} + +{/if} + +{#snippet flowRestartButton( + state: ReturnType, + step: string, + triggerStyle: 'button' | 'link' +)} + {#if job} + { + onNavigate('/run/' + newJobId + '?workspace=' + workspaceId) + }} + flowPath={job.script_path} + flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined} + disabled={!$enterpriseLicense} + enterpriseOnly={!$enterpriseLicense} + {triggerStyle} + unifiedSize="sm" + /> + {/if} +{/snippet} + + + + + +{#if loadError?.status === 403} +
+
+ +
+

+ This run exists in {workspaceId}, but you don't have + permission to view it. +

+

+ Ask a colleague who can see it to open the run and use the + Share button to send you a read-only link. Opening that + link will grant you access to this run (and its steps). +

+
+
+
+ +
+
+
+{:else if notfound || (job?.workspace_id != undefined && workspaceId != undefined && job?.workspace_id != workspaceId)} + {@const currentWorkspace = $userWorkspaces.find((w) => w.id === workspaceId)} + {@const otherWorkspaces = $userWorkspaces.filter((w) => w.id !== workspaceId)} +
+
+
+ +
+

Run not found

+

+ No run with ID + {runId} + exists in + {currentWorkspace?.name ?? workspaceId}. It + may belong to another workspace, or it may have been deleted. +

+
+ + {#if otherWorkspaces.length > 0} +
+

Look in another workspace

+
+ {#each otherWorkspaces as workspace (workspace.id)} + + {/each} +
+
+ {/if} + +
+ +
+
+{:else} + + + {#if embedded} +
+ {@render runActions()} +
+ {:else} + + {/if} + + {#snippet runActions()} + {@const isScript = job?.job_kind === 'script'} + {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)} + {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} + {#if job && 'deleted' in job && !job?.deleted && ($superadmin || (actingUser?.is_admin ?? false))} + { + job?.id && deleteCompletedJob(job.id) + }, + type: 'delete' + } + ]} + > + {#snippet buttonReplacement()} + + {/if} + {/if} + {#if job} + job && shareReadLink(job.id) + }, + { + displayName: 'Copy public link', + icon: Globe, + disabled: !canSharePublicly, + tooltip: canSharePublicly + ? "Read-only link that anyone on the internet can open, without logging in. It shows a minimal version of this page: this run's inputs, result and logs, and for a flow its graph plus every step's inputs, result, logs and code. The link cannot be revoked." + : 'Sharing a run publicly is restricted in this workspace. Ask an admin to share it, or to grant you a bypass on the ruleset.', + action: () => job && sharePublicLink(job.id) + } + ]} + > + {#snippet buttonReplacement()} + + {/snippet} + + {/if} + {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`} + + {@const viewHref = + isScript && embedded + ? `/scripts/get/${job?.script_path}?version=${job?.script_hash}` + : `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} + {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} +
+ { + debugInfo() + } + } + ]} + class="h-auto" + > + {#snippet buttonReplacement()} + + {/snippet} + +
+ {/if} + {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} + + {/if} + {#if persistentScriptDefinition !== undefined} + + {/if} + {#if canRunNow} + + {/if} + {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} + {#if !forceCancel} + + {:else} + + {/if} + {/if} + {#if job?.schedule_path} + + {/if} + {#if restartStep !== undefined && canRestart(restart)} + {@render flowRestartButton(restart, restartStep, 'button')} + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} + + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} + {#if !actingUser?.operator} + {#if editRights.canEditItem(isScript ? 'script' : 'flow', job?.script_path ?? '', {})} + + {#if showEditButton} + + + {/if} + {/if} + {#if !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces)} + + {/if} + {/if} + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} + + {/if} + {/snippet} +
+ +
+ + {#if job} + { + persistentScriptDrawer?.open?.(persistentScriptDefinition) + }} + {concurrencyKey} + /> + {/if} +
+ {#if job?.['deleted']} +
+ + The content of this run was deleted (by an admin, no less) + +
+
+ {/if} + + + {#if job?.job_kind === 'flow' || job?.job_kind === 'flowpreview'} +
+ + {#snippet errorAction()} + + {#if failedTopLevelStep && failedTopLevelStep !== AGENT_STEP_ID} + at step {failedTopLevelStep} + {#if $enterpriseLicense && canRestart(failedRestart)} + · + {@render flowRestartButton(failedRestart, failedTopLevelStep, 'link')} + on this flow version, or on a new one after you fix it + {/if} + {/if} + {/snippet} + + {#if suspendStatus} + + {/if} +
+ {/if} + + +
+
Inputs
+
+ + {#if job && currentJobIsLongRunning && showExplicitProgressTip && !scriptProgress && 'running' in job} + + + + {/if} +
+
+ + {#if job} + + {/if} + + {#if isNotFlow(job?.job_kind)} + {#if ['python3', 'bun', 'deno'].includes(job?.language ?? '') && (job?.job_kind == 'script' || isScriptPreview(job?.job_kind))} + + {/if} +
+ {#if job?.workflow_as_code_status && job.job_kind !== 'aiagent'} +
+

Workflow Timeline

+
+ +
+
+ {/if} + {#if scriptProgress} + + {/if} + + + {#if job?.language === 'dbt' && job?.script_path} +
+

Models

+ +
+ {/if} + + + {#if job} +
+

Result

+
+ {#if job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined)} + + {:else} +
No output is available yet
+ {/if} +
+
+ {#if job.id && job.workspace_id} + + + {/if} + {/if} + + +
+ + + + + + {#if isScriptPreview(job?.job_kind)} + + {/if} + + + + {#if job} +
+ {#if viewTab == 'logs'} +
+ +
+ {:else if viewTab == 'assets'} +
+ +
+ {:else if viewTab == 'traces'} +
+ +
+ {:else if viewTab == 'code'} + {#if job && 'raw_code' in job && job.raw_code} +
+ +
+ {:else if job} + No code available + {:else} + + {/if} + {:else if viewTab == 'stats'} +
+ +
+ {:else} +
Select a tab to view content
+ {/if} +
+ {/if} +
+
+ {:else if !job?.['deleted']} +
+ +
+ {#if job?.id} + { + job = newJob + }} + onDone={({ job: newJob }) => { + job = newJob + }} + initialJob={job} + {workspaceId} + bind:selectedJobStep + bind:suspendStatus + bind:isOwner + bind:localModuleStates={graphModuleStates} + bind:expandedSubflows + /> + {:else} + + {/if} +
+ {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/runs/ScriptRetryChain.svelte b/frontend/src/lib/components/runs/ScriptRetryChain.svelte index 2b2fc2449b..f7334467da 100644 --- a/frontend/src/lib/components/runs/ScriptRetryChain.svelte +++ b/frontend/src/lib/components/runs/ScriptRetryChain.svelte @@ -6,7 +6,10 @@ import { resource } from 'runed' import { canSkipRetryChainQuery } from './scriptRetryChain' - let { job }: { job: Job } = $props() + let { + job, + onNavigate = goto + }: { job: Job; onNavigate?: (url: string) => void | Promise } = $props() type AttemptColor = 'green' | 'red' | 'blue' | 'gray' type Attempt = { id: string; color: AttemptColor; current: boolean } @@ -141,7 +144,7 @@ size="xs" color="gray" variant="border" - onclick={() => goto(`/run/${self?.handledJob}?workspace=${ws}`)} + onclick={() => onNavigate(`/run/${self?.handledJob}?workspace=${ws}`)} > Handled run @@ -163,7 +166,7 @@ size="xs" color={attempt.color} variant={attempt.current ? 'contained' : 'border'} - onclick={() => goto(`/run/${attempt.id}?workspace=${ws}`)} + onclick={() => onNavigate(`/run/${attempt.id}?workspace=${ws}`)} > {i === 0 ? 'Original' : `Attempt ${i}`} @@ -180,7 +183,7 @@ size="xs" color={handler.color} variant="border" - onclick={() => goto(`/run/${handler.id}?workspace=${ws}`)} + onclick={() => onNavigate(`/run/${handler.id}?workspace=${ws}`)} > {HANDLER_LABEL[handler.kind]} diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index ca5fc2a0e5..5c8299de15 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -28,6 +28,8 @@ kind, path, version, + locationHash = '', + dbtRetryFrom, workspaceId, tabId, container, @@ -42,6 +44,10 @@ path: string /** Deployed version this tab is pinned to, from its URL's `?version=`. */ version?: string + /** `#`-fragment of the tab's URL: the args a run's `Run again` prefills the form with. */ + locationHash?: string + /** The failed dbt run that `Run again` offers to resume, from `?dbt_retry_from=`. */ + dbtRetryFrom?: string workspaceId: string /** Whether this side is the one on screen. Hidden sides stay mounted, so this gates * the window-level keyboard handler — several instances listen on the window. */ @@ -83,10 +89,20 @@ reloadKey++ } - const searchParams = $derived(version ? new URLSearchParams({ version }) : undefined) + const searchParams = $derived( + version || dbtRetryFrom + ? new URLSearchParams({ + ...(version ? { version } : {}), + ...(dbtRetryFrom ? { dbt_retry_from: dbtRetryFrom } : {}) + }) + : undefined + ) // Remounts the detail page below, which loads on mount. - const loadKey = $derived(`${workspaceId}/${kind}/${path}/${version ?? ''}/${reloadKey}`) + // The fragment is part of it: the detail pages seed their form from it once, on mount. + const loadKey = $derived( + `${workspaceId}/${kind}/${path}/${version ?? ''}/${locationHash}/${reloadKey}` + ) // Nothing deployed at this path. Stamped with the load it answers, so a refetch or a // re-pointed tab is never answered from an earlier load's verdict. let notFoundFor: string | undefined = $state(undefined) @@ -214,6 +230,7 @@ hash={path} workspace={workspaceId} {searchParams} + {locationHash} {onNavigate} {active} embedded @@ -224,6 +241,7 @@ {path} workspace={workspaceId} {searchParams} + {locationHash} {onNavigate} {active} embedded diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 3b8e722441..1ecb921c98 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -97,6 +97,9 @@ ? (new URL(tab.url, 'http://x').searchParams.get('version') ?? undefined) : undefined ) + // What a run's `Run again` hands the deployed page it opens: the run's args in the + // fragment, and for a dbt run the failure it could resume instead. + const viewerUrl = $derived(slot.kind === 'viewer' ? new URL(tab.url, 'http://x') : undefined) // The path an editor reports is its deployed one, which a staged rename can make // differ from this tab's — so the view opens on what actually exists. @@ -147,6 +150,7 @@ // A page item's editor reads its draft only when it loads, so a reload remounts it. let pageItemReloadNonce = $state(0) let pageListReloadNonce = $state(0) + let runReloadNonce = $state(0) export function reload() { // A live editor shares the runtime store the chat mutates, so generic chat @@ -168,6 +172,10 @@ pageListReloadNonce++ return } + if (slot.kind === 'run') { + runReloadNonce++ + return + } try { const win = frame?.contentWindow if (!win) return @@ -393,6 +401,8 @@ kind={itemKind} path={itemPath} version={pinnedVersion} + locationHash={viewerUrl?.hash} + dbtRetryFrom={viewerUrl?.searchParams.get('dbt_retry_from') ?? undefined} {workspaceId} tabId={tab.id} container={overlayHostEl} @@ -444,6 +454,29 @@ {/await} {/if} +{:else if slot.kind === 'run' && mounted && runtime} +
+ {#if overlayHostEl} + {#await import('./RunViewerView.svelte')} + {@render editorLoading()} + {:then Module} + + + {/await} + {/if} +
{:else if slot.kind === 'artifact' && mounted}
+ /* + * A run in a session preview tab, rendered by the component the workspace `/run/` page + * renders. Everything it reaches is scoped to the run's workspace through `workspace`, and + * every navigation it attempts is caught here and turned into a move inside the preview + * panel, so nothing takes the browser out of the session. + */ + import { setContext, untrack } from 'svelte' + import RunDetail from '$lib/components/runs/RunDetail.svelte' + import type { SessionRuntime } from './sessionRuntime.svelte' + import { + parseHistoricalScriptEdit, + parsePreviewItemRoute, + parseRunRoute, + previewLocationLabel, + barePath + } from './previewRouter' + + let { + runtime, + jobId, + workspaceId, + tabId, + container, + reloadNonce = 0 + }: { + runtime: SessionRuntime + jobId: string + workspaceId: string + /** The preview tab hosting this run, which a move to another run re-points. */ + tabId: string + /** The tab's host element, which also holds the overlays the page portals out. */ + container: HTMLElement | undefined + /** Bumped to load the run again from the server. */ + reloadNonce?: number + } = $props() + + // Mounted in the preview panel, outside the chat's own subtree, as ItemViewerView is: the + // run page's AI entry points would otherwise offer a session from inside one. + setContext( + 'aiChatManager', + untrack(() => runtime.manager) + ) + + // Everything the run page navigates to, resolved against the preview panel. Another run — + // a re-run, a retry, the parent job — is still this tab's subject, so it re-points in + // place; an item or any other page opens beside it. Nothing reaches the top-level router. + function onNavigate(url: string): void { + const parsed = new URL(url, window.location.origin) + const href = `${parsed.pathname}${parsed.search}${parsed.hash}` + if (parseRunRoute(href)) { + runtime.previewTabs.navigate({ type: 'page', href, label: previewLocationLabel(href) }, tabId) + return + } + const route = parsePreviewItemRoute(href) + // Legacy drag-and-drop apps and historical script edits have no in-panel host, so they + // fall through to a page tab. So does `Run again`, whose args ride in the fragment, and + // a link to one version: opening an item focuses a tab already on it as it stands, + // where a page tab's URL is written whole, pin and fragment included. + if ( + route && + (route.kind !== 'app' || route.raw_app) && + !parseHistoricalScriptEdit(href) && + !parsed.hash && + !parsed.searchParams.has('version') + ) { + runtime.previewTabs.open({ + type: 'item', + item: { path: route.itemPath, summary: '', kind: route.kind, raw_app: route.raw_app }, + mode: route.mode + }) + return + } + runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(barePath(href)) }) + } + + // Every in-app link under the tab, not only the ones that call `onNavigate`: a plain anchor + // followed by the browser would take it out of the session. Listened for on `body`, as + // ItemViewerView does and for the same reason. + $effect(() => { + const el = container + if (!el) return + function onClick(e: MouseEvent) { + if (e.defaultPrevented || e.button !== 0) return + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + const target = e.target as Element | null + if (!target || !el!.contains(target)) return + const a = target.closest?.('a[href]') as HTMLAnchorElement | null + if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return + const url = new URL(a.href, window.location.href) + if (url.origin !== window.location.origin) return + e.preventDefault() + if (a.getAttribute('href')?.startsWith('#')) return + onNavigate(`${url.pathname}${url.search}${url.hash}`) + } + document.body.addEventListener('click', onClick) + return () => document.body.removeEventListener('click', onClick) + }) + + +
+ + {#key `${workspaceId}/${jobId}/${reloadNonce}`} + + {/key} +
diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index 038691da55..8c9da253bb 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -316,6 +316,18 @@ describe('resolvePreviewTab', () => { expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' }) }) + it('mounts a run in process, in the workspace its URL names', () => { + expect(resolvePreviewTab('/run/01a0?workspace=fork')).toEqual({ + kind: 'run', + jobId: '01a0', + workspace: 'fork' + }) + expect(resolvePreviewTab('/run/01a0')).toEqual({ kind: 'run', jobId: '01a0' }) + expect(resolvePreviewTab('/run/01a0?workspace=fork&view_token=t')).toEqual({ kind: 'iframe' }) + // The runs of one item are a list page, not a run. + expect(resolvePreviewTab('/runs/f/a/b')).toEqual({ kind: 'iframe' }) + }) + it('mounts the list page of a page item kind in process, whatever its filters', () => { expect(resolvePreviewTab('/variables?path_start=f%2Fa%2F')).toEqual({ kind: 'pagelist', diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 2de5354c4d..e9d8c432fe 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -464,9 +464,9 @@ export function previewLocationLabel(url: string): string { if (page) return page.label const trigger = triggerLabelForPath(url) if (trigger) return trigger - const run = barePath(url).match(/^\/run\/([^/?#]+)/) + const run = parseRunRoute(url) // Job ids are time-ordered, so runs started moments apart share their head. - if (run) return `Run ${decodeURIComponent(run[1]).slice(-8)}` + if (run) return `Run ${run.jobId.slice(-8)}` // The runs of one item, as a detail page's Runs button links them. const itemRuns = barePath(url).match(/^\/runs\/(.+)$/) if (itemRuns) return `Runs · ${decodeURIComponent(itemRuns[1]).split('/').pop()}` @@ -550,6 +550,25 @@ export function parseRunFormRoute(url: string): { toolCallId: string; label: str } } +/** The run a `/run/` location shows, in the workspace its query names (the opener's, + * when it names none). */ +export function parseRunRoute( + url: string +): { jobId: string; workspace?: string; viewToken: boolean } | null { + const m = barePath(url).match(/^\/run\/([^/?#]+)$/) + if (!m) return null + try { + const query = new URL(url, 'http://_').searchParams + return { + jobId: decodeURIComponent(m[1]), + workspace: query.get('workspace') || undefined, + viewToken: query.has('view_token') + } + } catch { + return null + } +} + export function runFormUrl(toolCallId: string, label: string): string { return `runform:${encodeURIComponent(toolCallId)}#${encodeURIComponent(label)}` } @@ -575,7 +594,7 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:') // flow, raw app) mounts its per-(kind,path) cell editor on `/edit/` and its viewer // on `/get/`; a `/pipeline/` route mounts the data-pipeline graph editor of // that folder (`path` is the folder); the list page of a page item kind mounts that -// list; everything +// list; a `/run/` route without a share token mounts that run's page; everything // else (static pages, regular drag-and-drop apps, any other route) stays an iframe. export type PreviewSlot = | { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string } @@ -584,6 +603,7 @@ export type PreviewSlot = | { kind: 'runform'; toolCallId: string } | { kind: 'pageitem'; ref: PageItemRef } | { kind: 'pagelist'; path: string } + | { kind: 'run'; jobId: string; workspace?: string } | { kind: 'iframe' } export function resolvePreviewTab(url: string): PreviewSlot { @@ -595,6 +615,12 @@ export function resolvePreviewTab(url: string): PreviewSlot { if (pageItem) return { kind: 'pageitem', ref: pageItem } const route = barePath(url) if (isPageItemListPath(route)) return { kind: 'pagelist', path: route } + const run = parseRunRoute(url) + // A share link's token authorizes every request of the window that installs it, so a run + // opened through one keeps a frame of its own rather than lending the token to the session. + if (run && !run.viewToken) { + return { kind: 'run', jobId: run.jobId, ...(run.workspace && { workspace: run.workspace }) } + } const pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) { return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder } @@ -628,6 +654,7 @@ export function workspacePageHref(location: string): string | undefined { case 'editor': case 'viewer': case 'pagelist': + case 'run': case 'iframe': return location default: { diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 2995366ae0..8d242912dc 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -471,6 +471,7 @@ function createRuntime(session: Session): SessionRuntime { slotKind !== 'iframe' && slotKind !== 'pageitem' && slotKind !== 'pagelist' && + slotKind !== 'run' && slotKind !== 'viewer' ) return undefined @@ -593,8 +594,8 @@ function createRuntime(session: Session): SessionRuntime { onTabOpened: logTabUsage }) - // Let the jobs tray open a run in this session's preview panel (as an iframe - // tab over the run page). The global side-panel chat leaves this unset and + // Let the jobs tray open a run in this session's preview panel (as a tab on + // the run's page). The global side-panel chat leaves this unset and // falls back to a new browser tab. manager.openRunInPreview = ({ jobId, workspace, label }) => { previewTabs.open({ diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 48931c7871..ca5d8c83ad 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -1,1373 +1,13 @@ - - - - -{#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} - - - {#snippet actions()} -
-
- -
- -
- {/snippet} -

-					
-			
-
-
-{/if} -{#if !job || (job?.job_kind != 'flow' && job?.job_kind != 'flownode' && job?.job_kind != 'flowpreview')} - -{/if} - -{#snippet flowRestartButton( - state: ReturnType, - step: string, - triggerStyle: 'button' | 'link' -)} - {#if job} - { - goto('/run/' + newJobId + '?workspace=' + $workspaceStore) - }} - flowPath={job.script_path} - flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined} - disabled={!$enterpriseLicense} - enterpriseOnly={!$enterpriseLicense} - {triggerStyle} - unifiedSize="sm" - /> - {/if} -{/snippet} - - - - - -{#if loadError?.status === 403} -
-
- -
-

- This run exists in {$workspaceStore}, but you don't - have permission to view it. -

-

- Ask a colleague who can see it to open the run and use the - Share button to send you a read-only link. Opening that - link will grant you access to this run (and its steps). -

-
-
-
- -
-
-
-{:else if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)} - {@const currentWorkspace = $userWorkspaces.find((w) => w.id === $workspaceStore)} - {@const otherWorkspaces = $userWorkspaces.filter((w) => w.id !== $workspaceStore)} -
-
-
- -
-

Run not found

-

- No run with ID - {page.params.run} - exists in - {currentWorkspace?.name ?? $workspaceStore}. It may belong to another workspace, or it may have been deleted. -

-
- - {#if otherWorkspaces.length > 0} -
-

Look in another workspace

-
- {#each otherWorkspaces as workspace (workspace.id)} - - {/each} -
-
- {/if} - -
- -
-
-{:else} - - - - - {#snippet runActions()} - {@const isScript = job?.job_kind === 'script'} - {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)} - {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} - {#if job && 'deleted' in job && !job?.deleted && ($superadmin || ($userStore?.is_admin ?? false))} - { - job?.id && deleteCompletedJob(job.id) - }, - type: 'delete' - } - ]} - > - {#snippet buttonReplacement()} - - {/if} - {/if} - {#if job} - job && shareReadLink(job.id) - }, - { - displayName: 'Copy public link', - icon: Globe, - disabled: !canSharePublicly, - tooltip: canSharePublicly - ? "Read-only link that anyone on the internet can open, without logging in. It shows a minimal version of this page: this run's inputs, result and logs, and for a flow its graph plus every step's inputs, result, logs and code. The link cannot be revoked." - : 'Sharing a run publicly is restricted in this workspace. Ask an admin to share it, or to grant you a bypass on the ruleset.', - action: () => job && sharePublicLink(job.id) - } - ]} - > - {#snippet buttonReplacement()} - - {/snippet} - - {/if} - {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`} - {@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} - {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} -
- { - debugInfo() - } - } - ]} - class="h-auto" - > - {#snippet buttonReplacement()} - - {/snippet} - -
- {/if} - {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} - - {/if} - {#if persistentScriptDefinition !== undefined} - - {/if} - {#if canRunNow} - - {/if} - {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} - {#if !forceCancel} - - {:else} - - {/if} - {/if} - {#if job?.schedule_path} - - {/if} - {#if restartStep !== undefined && canRestart(restart)} - {@render flowRestartButton(restart, restartStep, 'button')} - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} - {#if !$userStore?.operator} - {#if editRights.canEditItem(isScript ? 'script' : 'flow', job?.script_path ?? '', {})} - - {#if showEditButton} - - - {/if} - {/if} - {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} - - {/if} - {/if} - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - - {/if} - {/snippet} -
- -
- - {#if job} - { - persistentScriptDrawer?.open?.(persistentScriptDefinition) - }} - {concurrencyKey} - /> - {/if} -
- {#if job?.['deleted']} -
- - The content of this run was deleted (by an admin, no less) - -
-
- {/if} - - - {#if job?.job_kind === 'flow' || job?.job_kind === 'flowpreview'} -
- - {#snippet errorAction()} - - {#if failedTopLevelStep && failedTopLevelStep !== AGENT_STEP_ID} - at step {failedTopLevelStep} - {#if $enterpriseLicense && canRestart(failedRestart)} - · - {@render flowRestartButton(failedRestart, failedTopLevelStep, 'link')} - on this flow version, or on a new one after you fix it - {/if} - {/if} - {/snippet} - - {#if suspendStatus} - - {/if} -
- {/if} - - -
-
Inputs
-
- - {#if job && currentJobIsLongRunning && showExplicitProgressTip && !scriptProgress && 'running' in job} - - - - {/if} -
-
- - {#if job} - - {/if} - - {#if isNotFlow(job?.job_kind)} - {#if ['python3', 'bun', 'deno'].includes(job?.language ?? '') && (job?.job_kind == 'script' || isScriptPreview(job?.job_kind))} - - {/if} -
- {#if job?.workflow_as_code_status && job.job_kind !== 'aiagent'} -
-

Workflow Timeline

-
- -
-
- {/if} - {#if scriptProgress} - - {/if} - - - {#if job?.language === 'dbt' && job?.script_path} -
-

Models

- -
- {/if} - - - {#if job} -
-

Result

-
- {#if job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined)} - - {:else} -
No output is available yet
- {/if} -
-
- {#if job.id && job.workspace_id} - - - {/if} - {/if} - - -
- - - - - - {#if isScriptPreview(job?.job_kind)} - - {/if} - - - - {#if job} -
- {#if viewTab == 'logs'} -
- -
- {:else if viewTab == 'assets'} -
- -
- {:else if viewTab == 'traces'} -
- -
- {:else if viewTab == 'code'} - {#if job && 'raw_code' in job && job.raw_code} -
- -
- {:else if job} - No code available - {:else} - - {/if} - {:else if viewTab == 'stats'} -
- -
- {:else} -
Select a tab to view content
- {/if} -
- {/if} -
-
- {:else if !job?.['deleted']} -
- -
- {#if job?.id} - { - job = newJob - }} - onDone={({ job: newJob }) => { - job = newJob - }} - initialJob={job} - workspaceId={$workspaceStore} - bind:selectedJobStep - bind:suspendStatus - bind:isOwner - bind:localModuleStates={graphModuleStates} - bind:expandedSubflows - /> - {:else} - - {/if} -
- {/if} -
-{/if} - - From 3d74f6ea2a44e85d056411c293087741f6f1e12a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 14:10:05 +0200 Subject: [PATCH 03/28] feat: data table migration tools in ai sessions (#11723) * feat: data table migration tools and role guidance in ai sessions Co-Authored-By: Claude Opus 5.5 * feat: shared data table guidance and safer migration tool failures Co-Authored-By: Claude Opus 5.5 * fix: keep a migration that fails to apply and let the session rewrite it Co-Authored-By: Claude Opus 5.5 * fix: rewrite only a pending migration and qualify fork isolation Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ai_evals/README.md | 5 +- .../adapters/frontend/datatableSqlEngine.ts | 11 + ai_evals/adapters/frontend/mockBackend.ts | 130 +++++- .../adapters/frontend/vitestAdapter.test.ts | 27 +- ai_evals/cases/global.yaml | 51 +++ .../datatable_orders_migrations_seed.json | 36 ++ .../src/datatable_migrations.rs | 6 +- .../windmill-api-workspaces/src/workspaces.rs | 12 + backend/windmill-api/openapi.yaml | 4 + cli/src/guidance/skills.gen.ts | 48 +++ .../chat/datatableMigrationTools.test.ts | 141 +++++++ .../copilot/chat/datatableMigrationTools.ts | 391 ++++++++++++++++++ .../components/copilot/chat/datatableTools.ts | 1 + .../components/copilot/chat/global/core.ts | 31 +- system_prompts/README.md | 1 + system_prompts/auto-generated/index.d.ts | 1 + system_prompts/auto-generated/index.ts | 7 + system_prompts/auto-generated/prompts.d.ts | 1 + system_prompts/auto-generated/prompts.ts | 41 ++ .../auto-generated/skills/datatables/SKILL.md | 46 +++ system_prompts/base/datatable.md | 43 ++ system_prompts/generate.py | 13 + 22 files changed, 1033 insertions(+), 14 deletions(-) create mode 100644 ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json create mode 100644 frontend/src/lib/components/copilot/chat/datatableMigrationTools.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts create mode 100644 system_prompts/auto-generated/skills/datatables/SKILL.md create mode 100644 system_prompts/base/datatable.md diff --git a/ai_evals/README.md b/ai_evals/README.md index 44870ba2a2..9b8850c435 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -196,7 +196,10 @@ not have read is itself the failure — e.g. `write_variable.value` on a secret Global (and flow) initial fixtures can seed `workspace.datatables` so the `list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql` tools return seeded data during evals. Each entry is -`{ datatable_name, schemas: { : { : { columns, rows? } } } }`. +`{ datatable_name, schemas: { : {
: { columns, rows? } } } }`, +plus `migrations_enabled: true` and `migrations: [{ timestamp, name, code_up, code_down?, applied? }]` +for a datatable that keeps its schema as migrations, which the migration tools then +read and write in memory. SQL runs through a small in-memory engine (`datatableSqlEngine.ts`), not a real database. Writes are **stateful within a case**: `CREATE`/`DROP`/`INSERT`/`UPDATE`/ `DELETE` mutate the seeded datatable in place, so a later `list_datatables`, diff --git a/ai_evals/adapters/frontend/datatableSqlEngine.ts b/ai_evals/adapters/frontend/datatableSqlEngine.ts index 453eca286e..96a62f6804 100644 --- a/ai_evals/adapters/frontend/datatableSqlEngine.ts +++ b/ai_evals/adapters/frontend/datatableSqlEngine.ts @@ -30,6 +30,17 @@ export interface BenchmarkDatatableSeed { [table: string]: BenchmarkDatatableTableSeed } } + /** Whether the datatable keeps its schema as migrations. */ + migrations_enabled?: boolean + migrations?: BenchmarkDatatableMigrationSeed[] +} + +export interface BenchmarkDatatableMigrationSeed { + timestamp: number + name: string + code_up: string + code_down?: string + applied?: boolean } export interface DatatableSqlResult { diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 2f47e85be6..d8284039b9 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -692,10 +692,138 @@ export function listBenchmarkDatatables(workspace: string): DataTableTables[] | datatable_name: datatable.datatable_name, schemas: Object.fromEntries( Object.entries(datatable.schemas).map(([schema, tables]) => [schema, Object.keys(tables)]) - ) + ), + ...(datatable.migrations_enabled && { migrations_enabled: true }) })) } +// ============= Datatable migrations ============= + +function benchmarkDatatable(workspace: string, datatableName: string): BenchmarkDatatableSeed { + const datatable = (benchmarkWorkspaceRunnables.get(workspace)?.datatables ?? []).find( + (entry) => entry.datatable_name === datatableName + ) + if (!datatable) { + throw new Error(`datatable "${datatableName}" not found`) + } + return datatable +} + +/** Run a migration body through the in-memory engine, one statement at a time and without the + * transaction frame, so the tables it creates show up in later reads. */ +function applyBenchmarkMigrationSql(datatable: BenchmarkDatatableSeed, sql: string): void { + // Split on the semicolons outside single-quoted literals. + for (const statement of sql.match(/(?:'(?:[^']|'')*'|[^;'])+/g) ?? []) { + const trimmed = statement.replace(/^\s*--.*$/gm, '').trim() + if (trimmed !== '' && !/^(begin|end|commit)$/i.test(trimmed)) { + applyDatatableSql(datatable, trimmed) + } + } +} + +export function getBenchmarkDatatableMigrationsStatus(input: { + workspace: string + datatableName: string +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + if (!datatable.migrations_enabled) { + return { enabled: false, migrations: [] } + } + return { + enabled: true, + migrations: (datatable.migrations ?? []).map(({ applied, ...migration }) => ({ + ...migration, + status: applied ? ('ran' as const) : ('not_run' as const) + })) + } +} + +export function createBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + requestBody: { name: string; code_up: string; code_down?: string } +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + if (!datatable.migrations_enabled) { + throw new Error( + `Migrations are not enabled for data table '${input.datatableName}'. Enable them first.` + ) + } + const migrations = (datatable.migrations ??= []) + const timestamp = Math.max(20260101000000, ...migrations.map((m) => m.timestamp)) + 1 + migrations.push({ timestamp, ...input.requestBody }) + return { datatable: input.datatableName, timestamp, ...input.requestBody } +} + +export function upsertBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + requestBody: { timestamp: number; name: string; code_up: string; code_down?: string } +}): string { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const migrations = (datatable.migrations ??= []) + const existing = migrations.find((m) => m.timestamp === input.requestBody.timestamp) + if (existing?.applied) { + throw new Error( + `Migration ${existing.timestamp} on data table '${input.datatableName}' has already been applied and cannot be modified` + ) + } + if (existing) { + // Replaced whole, as the server does: a down left out is stored as none. + delete existing.code_down + Object.assign(existing, input.requestBody) + } else { + migrations.push({ ...input.requestBody }) + migrations.sort((a, b) => a.timestamp - b.timestamp) + } + return `Upserted migration ${input.requestBody.timestamp} in ${input.datatableName}` +} + +export function runBenchmarkDatatableMigrations(input: { + workspace: string + datatableName: string + upTo?: number + only?: number +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const applied: { version: number; name: string }[] = [] + for (const migration of datatable.migrations ?? []) { + if ( + migration.applied || + (input.only !== undefined && migration.timestamp !== input.only) || + (input.only === undefined && input.upTo !== undefined && migration.timestamp > input.upTo) + ) { + continue + } + applyBenchmarkMigrationSql(datatable, migration.code_up) + migration.applied = true + applied.push({ version: migration.timestamp, name: migration.name }) + } + return { applied } +} + +export function rollbackBenchmarkDatatableMigration(input: { + workspace: string + datatableName: string + only?: number +}) { + const datatable = benchmarkDatatable(input.workspace, input.datatableName) + const last = (datatable.migrations ?? []) + .filter((m) => m.applied && (input.only === undefined || m.timestamp === input.only)) + .at(-1) + if (!last) { + return { rolled_back: [] } + } + if (last.code_down === undefined) { + throw new Error( + `Cannot roll back migration ${last.timestamp} (${last.name}): it has no down migration` + ) + } + applyBenchmarkMigrationSql(datatable, last.code_down) + last.applied = false + return { rolled_back: [{ version: last.timestamp, name: last.name }] } +} + // ============= DuckLake catalogs and declared metrics ============= /** Seeded DuckLake names, or `null` for a non-benchmark workspace. */ diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 288c67e3eb..cb7469b45e 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -92,6 +92,11 @@ vi.mock('$lib/gen', async () => { getBenchmarkCompletedJob, getBenchmarkCompletedJobResultMaybe, getBenchmarkDatatableSchema, + getBenchmarkDatatableMigrationsStatus, + createBenchmarkDatatableMigration, + upsertBenchmarkDatatableMigration, + runBenchmarkDatatableMigrations, + rollbackBenchmarkDatatableMigration, getBenchmarkDraftForUser, getBenchmarkFlowByPath, getBenchmarkFlowAllResults, @@ -400,7 +405,27 @@ vi.mock('$lib/gen', async () => { schemaName: data.schemaName, tableName: data.tableName }) - : actual.WorkspaceService.getDataTableTableSchema(data) + : actual.WorkspaceService.getDataTableTableSchema(data), + getDatatableMigrationsStatus: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkDatatableMigrationsStatus(data) + : actual.WorkspaceService.getDatatableMigrationsStatus(data), + createDatatableMigration: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? createBenchmarkDatatableMigration(data) + : actual.WorkspaceService.createDatatableMigration(data), + upsertDatatableMigration: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? upsertBenchmarkDatatableMigration(data) + : actual.WorkspaceService.upsertDatatableMigration(data), + runDatatableMigrations: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? runBenchmarkDatatableMigrations(data) + : actual.WorkspaceService.runDatatableMigrations(data), + rollbackDatatableMigrations: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? rollbackBenchmarkDatatableMigration(data) + : actual.WorkspaceService.rollbackDatatableMigrations(data) }), DataMetricService: wrapService(actual.DataMetricService, { listDataMetrics: async (data: { workspace: string }) => diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index b66af3e8dd..4b6704b229 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -901,6 +901,57 @@ - treats creating the table as a normal SQL statement and does not claim a separate registration step is needed - does not write a script to create the table +# The same request on a data table that keeps its schema as migrations: the table is +# created by a migration, since plain SQL would leave it out of the history. +- id: global-datatable-schema-change-uses-migration + prompt: |- + Add a new table called refunds to our workspace data table, with an order id and a refund amount. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - create_datatable_migration + forbiddenToolsUsed: + - write_script + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: create_datatable_migration + field: code_up + stringIncludesAnyOf: + - create table + skipJudge: true + judgeChecklist: + - notices that the data table keeps its schema as migrations + - creates the refunds table through a migration rather than a plain CREATE TABLE statement + - includes an order id and a refund amount column + - gives the migration a down that drops the table + +# Row data is not schema: it is written with plain SQL even where migrations are enabled. +- id: global-datatable-row-change-skips-migration + prompt: |- + Mark order number 2 as shipped in our workspace data table. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json + runtime: + maxTurns: 12 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - exec_datatable_sql + forbiddenToolsUsed: + - create_datatable_migration + - write_script + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - runs an UPDATE on the orders table setting the status of order id 2 to shipped + - does not create a migration for a change to row data + - id: global-test25-datatable-mutate-rows prompt: |- Mark order number 2 as shipped in our workspace data table. diff --git a/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json b/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json new file mode 100644 index 0000000000..d5227d9af8 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/datatable_orders_migrations_seed.json @@ -0,0 +1,36 @@ +{ + "workspace": { + "datatables": [ + { + "datatable_name": "main", + "migrations_enabled": true, + "migrations": [ + { + "timestamp": 20260501090000, + "name": "create_orders", + "code_up": "BEGIN;\n\nCREATE TABLE orders (id serial PRIMARY KEY, customer_id int, total numeric, status text, created_at timestamptz DEFAULT now());\n\nEND;", + "code_down": "BEGIN;\n\nDROP TABLE orders;\n\nEND;", + "applied": true + } + ], + "schemas": { + "public": { + "orders": { + "columns": { + "id": "int4", + "customer_id": "int4", + "total": "numeric", + "status": "text", + "created_at": "timestamptz" + }, + "rows": [ + { "id": 1, "customer_id": 1, "total": 42.5, "status": "shipped", "created_at": "2026-05-01T10:00:00Z" }, + { "id": 2, "customer_id": 2, "total": 19.99, "status": "pending", "created_at": "2026-05-02T11:30:00Z" } + ] + } + } + } + } + ] + } +} diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index e63e69d4d5..21e460359e 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -880,7 +880,11 @@ struct DatatableMigrationsStatusResult { /// Whether the SQL-migrations feature is enabled for a data table. Honors the /// explicit `migrations_enabled` flag; when unset (data tables predating the /// feature) it is considered enabled only if migrations already exist. -async fn datatable_migrations_enabled(db: &DB, w_id: &str, datatable_name: &str) -> Result { +pub(crate) async fn datatable_migrations_enabled( + db: &DB, + w_id: &str, + datatable_name: &str, +) -> Result { let flag: Option = sqlx::query_scalar!( "SELECT (ws.datatable->'datatables'->$2->>'migrations_enabled')::boolean \ FROM workspace_settings ws WHERE ws.workspace_id = $1", diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 3bfcef5c96..bdb3b59e6e 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2325,6 +2325,9 @@ struct DataTableTables { /// What the role the listing connected as may create. can_create_schema: bool, creatable_schemas: Vec, + /// Whether its schema is kept as versioned migrations, which a change made with plain SQL + /// would bypass. + migrations_enabled: bool, } #[derive(Deserialize)] @@ -2570,8 +2573,17 @@ async fn list_one_datatable_tables( default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(), can_create_schema: false, creatable_schemas: vec![], + migrations_enabled: false, }; let result: Result<()> = async { + // Read from the workspace settings alone, so it is known even when the database is not + // reachable. + entry.migrations_enabled = crate::datatable_migrations::datatable_migrations_enabled( + db, + w_id, + &entry.datatable_name, + ) + .await?; let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?; entry.instance = governing.role_cluster().is_some(); let usable = diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 433d7c00be..53996db1dc 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -37336,6 +37336,7 @@ components: default_role, can_create_schema, creatable_schemas, + migrations_enabled, ] properties: datatable_name: @@ -37369,6 +37370,9 @@ components: description: the schemas the role the listing connected as may create in items: type: string + migrations_enabled: + type: boolean + description: whether its schema is kept as versioned migrations, which a change made with plain SQL would bypass DataTableTableSchema: type: object diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index d90a401260..0a880b05cf 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -34,6 +34,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "resources", description: "MUST use when managing resources." }, { name: "write-workflow-as-code", description: "MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow." }, { name: "write-pipeline", description: "MUST use when creating or modifying a data pipeline, a set of scripts marked `pipeline` and wired together by `on` / `materialize` annotations." }, + { name: "datatables", description: "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions." }, { name: "cli-commands", description: "MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`." }, { name: "preview", description: "MUST use when opening the Windmill dev page / visual preview of a flow, script, or app. Triggers on words like preview, open, navigate to, visualize, see the flow/app/script, and after writing a flow/script/app for visual verification." }, ]; @@ -8123,6 +8124,53 @@ Node \`f/sales/orders_daily\` (runs when \`orders\` is produced, writes a rollup SELECT date_trunc('day', ts) AS day, count(*) AS n FROM ducklake.main.orders GROUP BY 1 \`\`\` +`, + "datatables": `--- +name: datatables +description: MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions. +--- + +# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, \`main\` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an \`up\` and an optional \`down\`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in \`BEGIN; ... END;\` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as \`CREATE INDEX CONCURRENTLY\`: such a migration can fail half applied. +- Give it a \`down\` that undoes the \`up\` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. + +\`wmill datatable migrate new \` scaffolds \`migrations/datatable//_.up.sql\` and \`.down.sql\`, \`wmill datatable migrate up\` pushes the local files and applies what is pending, \`wmill datatable migrate down\` rolls back the most recent one and \`wmill datatable migrate status\` lists them. All take \`-d \` and default to \`main\`. + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (\`CREATE SCHEMA IF NOT EXISTS\`), and point what you are building at it. A raw app names its schema in its \`data\` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. \`admin\` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (\`wmill.datatable('main', { role: 'analyst' })\`, \`role='analyst'\` in Python, \`datatable://main?role=analyst\`) and holds that role alone, or names none and is resolved from the data table's default role; +- \`permission denied\` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to \`admin\` to get past it; +- a migration runs as the role its SQL declares on its first line, \`-- role \`, the same one in its \`down\`. One that declares none runs as \`admin\`, which only those who may use \`admin\` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. `, "cli-commands": `--- name: cli-commands diff --git a/frontend/src/lib/components/copilot/chat/datatableMigrationTools.test.ts b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.test.ts new file mode 100644 index 0000000000..086adce9e9 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { statusMock, createMock, upsertMock, runMock } = vi.hoisted(() => ({ + upsertMock: vi.fn(), + statusMock: vi.fn(), + createMock: vi.fn(), + runMock: vi.fn() +})) + +vi.mock('./shared', () => ({ + createToolDef: (_schema: unknown, name: string, description: string) => ({ + type: 'function', + function: { name, description, parameters: {} } + }) +})) + +vi.mock('$lib/gen', () => ({ + WorkspaceService: { + getDatatableMigrationsStatus: statusMock, + createDatatableMigration: createMock, + upsertDatatableMigration: upsertMock, + runDatatableMigrations: runMock + } +})) + +import { getDatatableMigrationTools } from './datatableMigrationTools' + +function create(args: Record) { + const tool = getDatatableMigrationTools().find( + (entry) => entry.def.function.name === 'create_datatable_migration' + )! + return tool.fn({ + args: { + datatable_name: 'main', + name: 'add_refunds', + code_up: 'BEGIN;\nCREATE TABLE refunds ();\nEND;', + ...args + }, + workspace: 'test-workspace', + helpers: {}, + toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() } as any, + toolId: 'tool-1' + }) +} + +const applied = { timestamp: 10, name: 'init', code_up: '', status: 'ran' } +const created = { timestamp: 30, name: 'add_refunds', code_up: '', status: 'not_run' } + +beforeEach(() => { + for (const mock of [statusMock, createMock, upsertMock, runMock]) mock.mockReset() + createMock.mockResolvedValue({ timestamp: 30 }) + statusMock.mockResolvedValue({ enabled: true, migrations: [applied, created] }) + runMock.mockResolvedValue({ applied: [{ version: 30, name: 'add_refunds' }] }) +}) + +describe('create_datatable_migration', () => { + it('applies only the migration it created', async () => { + const result = await create({}) + expect(runMock).toHaveBeenCalledWith({ + workspace: 'test-workspace', + datatableName: 'main', + only: 30 + }) + expect(result).toContain('Applied migration 30 (add_refunds)') + }) + + it('leaves it pending when an earlier migration is not applied', async () => { + statusMock.mockResolvedValue({ + enabled: true, + migrations: [ + applied, + { timestamp: 20, name: 'earlier', code_up: '', status: 'not_run' }, + created + ] + }) + const result = await create({}) + expect(runMock).not.toHaveBeenCalled() + expect(result).toContain('20 (earlier)') + }) + + it('keeps a migration that fails to apply and rewrites it on request', async () => { + runMock.mockRejectedValueOnce({ body: 'relation "refunds" already exists' }) + const failed = await create({}) + expect(failed).toContain('relation "refunds" already exists') + expect(failed).toContain('with version 30') + + statusMock.mockResolvedValue({ + enabled: true, + migrations: [applied, { ...created, code_down: 'DROP TABLE refunds;' }] + }) + await create({ version: 30, code_up: 'BEGIN;\nCREATE TABLE refunds2 ();\nEND;' }) + expect(createMock).toHaveBeenCalledTimes(1) + expect(upsertMock).toHaveBeenCalledWith({ + workspace: 'test-workspace', + datatableName: 'main', + requestBody: { + timestamp: 30, + code_down: 'DROP TABLE refunds;', + name: 'add_refunds', + code_up: 'BEGIN;\nCREATE TABLE refunds2 ();\nEND;' + } + }) + expect(runMock).toHaveBeenLastCalledWith({ + workspace: 'test-workspace', + datatableName: 'main', + only: 30 + }) + }) + + it('refuses to rewrite a version that is not pending', async () => { + const result = await create({ version: 10 }) + expect(upsertMock).not.toHaveBeenCalled() + expect(runMock).not.toHaveBeenCalled() + expect(result).toContain('cannot be rewritten') + }) + + it('does not call unreadable earlier migrations pending', async () => { + statusMock.mockResolvedValue({ + enabled: true, + migrations: [ + { ...applied, status: 'unknown' }, + { ...created, status: 'unknown' } + ], + error: 'connection refused' + }) + const result = await create({}) + expect(runMock).not.toHaveBeenCalled() + expect(result).toContain('connection refused') + expect(result).not.toContain('10 (init)') + }) + + it('declares the role above the SQL of both directions', async () => { + await create({ role: 'app_rw', code_down: 'DROP TABLE refunds;', apply: false }) + expect(createMock.mock.calls[0][0].requestBody).toEqual({ + name: 'add_refunds', + code_up: '-- role app_rw\nBEGIN;\nCREATE TABLE refunds ();\nEND;', + code_down: '-- role app_rw\nDROP TABLE refunds;' + }) + expect(runMock).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts new file mode 100644 index 0000000000..3a2476b9d0 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts @@ -0,0 +1,391 @@ +import { z } from 'zod' +import { WorkspaceService } from '$lib/gen' +import { withMigrationRole } from '$lib/components/datatableMigrationRole' +import { createToolDef, type ToolCallbacks } from './shared' +import { NONE, RUN_PREVIEW, type SessionTool } from './sessionCapabilities' + +/** + * Tools over a data table's versioned SQL migrations (`datatable_migrations.rs`). + * + * A data table with migrations enabled keeps its schema as an ordered list of definitions, each + * applied once per database and recorded there. That list is what git sync and a workspace merge + * carry, so a schema change made with plain SQL exists in one database only. + */ + +const memo = (factory: () => T): (() => T) => { + let cached: T | undefined + return () => (cached ??= factory()) +} + +function apiErrorMessage(e: unknown): string { + const err = e as { body?: unknown; message?: unknown } | undefined + const body = err?.body + if (typeof body === 'string' && body !== '') return body + if (typeof err?.message === 'string') return err.message + return String(e) +} + +function fail(toolCallbacks: ToolCallbacks, toolId: string, message: string): string { + toolCallbacks.setToolStatus(toolId, { content: message, error: message }) + return message +} + +const migrationLabel = (m: { version: number; name: string }) => `${m.version} (${m.name})` + +const datatableNameSchema = () => + z.string().describe('The datatable the migrations belong to, e.g. "main".') + +// ============= Tool definitions ============= + +const getListMigrationsSchema = memo(() => + z.object({ + datatable_name: datatableNameSchema(), + version: z + .number() + .optional() + .describe('Return the SQL of this one migration instead of the list.') + }) +) +const getListMigrationsToolDef = memo(() => + createToolDef( + getListMigrationsSchema(), + 'list_datatable_migrations', + "List a datatable's migrations in order, each with its version, name and whether it is applied or pending. Pass `version` to read one migration's SQL." + ) +) + +const getCreateMigrationSchema = memo(() => + z.object({ + datatable_name: datatableNameSchema(), + name: z + .string() + .regex(/^[A-Za-z0-9_-]+$/) + .describe( + 'Short name of the change, e.g. "add_refunds_table". Letters, digits, "_" and "-" only.' + ), + code_up: z + .string() + .describe( + 'The SQL that makes the change. Wrap the statements in `BEGIN; ... END;` so they apply atomically, unless one of them cannot run in a transaction.' + ), + code_down: z + .string() + .optional() + .describe( + 'The SQL that undoes `code_up`. Without it the migration cannot be rolled back, so provide it whenever the change is reversible.' + ), + role: z + .string() + .regex(/^[A-Za-z0-9_-]{1,63}$/) + .optional() + .describe( + 'The datatable role the migration runs as, on a datatable that has roles. Omit to run as admin, which only the datatable admins may do.' + ), + version: z + .number() + .optional() + .describe( + 'Rewrite this migration instead of adding one: only for a pending migration, typically the one that just failed to apply.' + ), + apply: z + .boolean() + .optional() + .describe('Set false to save the migration as pending without running it. Defaults to true.') + }) +) +const getCreateMigrationToolDef = memo(() => + createToolDef( + getCreateMigrationSchema(), + 'create_datatable_migration', + 'Change the schema of a datatable that has migrations enabled: saves the change as a new migration and applies it. One that fails to apply stays saved as pending: call this again with its `version` to fix it.' + ) +) + +const getRunMigrationsSchema = memo(() => z.object({ datatable_name: datatableNameSchema() })) +const getRunMigrationsToolDef = memo(() => + createToolDef( + getRunMigrationsSchema(), + 'run_datatable_migrations', + "Apply a datatable's pending migrations, oldest first. It stops at the first one that fails." + ) +) + +const getRollbackMigrationSchema = memo(() => + z.object({ + datatable_name: datatableNameSchema(), + version: z + .number() + .describe('The applied migration to undo, normally the most recent one, as listed.') + }) +) +const getRollbackMigrationToolDef = memo(() => + createToolDef( + getRollbackMigrationSchema(), + 'rollback_datatable_migration', + 'Undo one applied migration of a datatable by running its down SQL. The migration stays in the list as pending.' + ) +) + +// ============= Tools ============= + +export function getDatatableMigrationTools(): SessionTool<{}>[] { + return [ + { + requires: NONE, + def: getListMigrationsToolDef(), + planModeSafe: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const { datatable_name, version } = getListMigrationsSchema().parse(args) + toolCallbacks.setToolStatus(toolId, { + content: `Listing migrations of "${datatable_name}"...` + }) + try { + const status = await WorkspaceService.getDatatableMigrationsStatus({ + workspace, + datatableName: datatable_name + }) + if (!status.enabled) { + toolCallbacks.setToolStatus(toolId, { + content: `Migrations are not enabled on "${datatable_name}"` + }) + return `Migrations are not enabled on datatable "${datatable_name}": its schema is changed with plain SQL and there is no history to list. Enabling them is a workspace admin's decision, made in Workspace settings → Data Tables.` + } + if (version !== undefined) { + const migration = status.migrations.find((m) => m.timestamp === version) + if (!migration) { + return fail( + toolCallbacks, + toolId, + `No migration ${version} on datatable "${datatable_name}"` + ) + } + toolCallbacks.setToolStatus(toolId, { + content: `Read migration ${migrationLabel({ version, name: migration.name })}` + }) + return JSON.stringify( + { + version, + name: migration.name, + status: migrationStatus(migration.status), + code_up: migration.code_up, + ...(migration.code_down != null && { code_down: migration.code_down }) + }, + null, + 2 + ) + } + toolCallbacks.setToolStatus(toolId, { + content: `Listed ${status.migrations.length} migration(s) of "${datatable_name}"` + }) + return JSON.stringify( + { + migrations: status.migrations.map((m) => ({ + version: m.timestamp, + name: m.name, + status: migrationStatus(m.status) + })), + // Set when the database could not be read, which is why every status is unknown. + ...(status.error && { error: status.error }) + }, + null, + 2 + ) + } catch (e) { + return fail(toolCallbacks, toolId, `Error listing migrations: ${apiErrorMessage(e)}`) + } + } + }, + { + // `ensure_not_operator` in datatable_migrations.rs: the condition `run_preview` stands for. + requires: RUN_PREVIEW, + def: getCreateMigrationToolDef(), + requiresConfirmation: true, + confirmationMessage: (args) => + `${args?.version === undefined ? 'Create' : 'Rewrite'}${args?.apply === false ? '' : ' and apply'} data table migration`, + queuedLabel: (args) => `Write migration "${args?.name ?? ''}"`, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = getCreateMigrationSchema().parse(args) + const datatableName = parsed.datatable_name + toolCallbacks.setToolStatus(toolId, { + content: `Saving migration "${parsed.name}" on "${datatableName}"...` + }) + // The role is an annotation in the SQL, read from above everything else, and a rollback + // has to run as the role that created what it drops. + const definition = { + name: parsed.name, + code_up: withRole(parsed.code_up, parsed.role), + ...(parsed.code_down !== undefined && { + code_down: withRole(parsed.code_down, parsed.role) + }) + } + let version: number + try { + if (parsed.version === undefined) { + const created = await WorkspaceService.createDatatableMigration({ + workspace, + datatableName, + requestBody: definition + }) + version = created.timestamp + } else { + version = parsed.version + // The server inserts at a version it does not know, which would wedge a new + // migration into the middle of the history, and stores a down left out as none. + const { migrations } = await WorkspaceService.getDatatableMigrationsStatus({ + workspace, + datatableName + }) + const existing = migrations.find((m) => m.timestamp === version) + if (existing?.status !== 'not_run') { + return fail( + toolCallbacks, + toolId, + `Migration ${version} is not a pending migration of datatable "${datatableName}", so it cannot be rewritten. Omit \`version\` to add a new migration.` + ) + } + await WorkspaceService.upsertDatatableMigration({ + workspace, + datatableName, + requestBody: { + timestamp: version, + ...(existing.code_down != null && { code_down: existing.code_down }), + ...definition + } + }) + } + } catch (e) { + return fail(toolCallbacks, toolId, `Error saving migration: ${apiErrorMessage(e)}`) + } + const label = migrationLabel({ version, name: parsed.name }) + if (parsed.apply === false) { + toolCallbacks.setToolStatus(toolId, { content: `Saved migration ${label} as pending` }) + return `Saved migration ${label} as pending. It is not applied yet.` + } + + // Applying it alone would run it ahead of migrations it may depend on, and applying + // those with it would run SQL the user was never shown. + let earlierPending: string[] + try { + const status = await WorkspaceService.getDatatableMigrationsStatus({ + workspace, + datatableName + }) + const earlier = status.migrations.filter((m) => m.timestamp < version) + // Unreadable is not pending: reporting it so would send the user to apply + // migrations that already ran. + if (status.error || earlier.some((m) => m.status === 'unknown')) { + throw new Error(status.error ?? 'the applied migrations could not be read') + } + earlierPending = earlier + .filter((m) => m.status === 'not_run') + .map((m) => migrationLabel({ version: m.timestamp, name: m.name })) + } catch (e) { + return fail( + toolCallbacks, + toolId, + `Saved migration ${label} as pending, but could not check what else is pending, so it was not applied: ${apiErrorMessage(e)}` + ) + } + if (earlierPending.length > 0) { + toolCallbacks.setToolStatus(toolId, { + content: `Saved migration ${label}; ${earlierPending.length} earlier migration(s) still pending` + }) + return `Saved migration ${label} as pending without applying it: earlier migrations are not applied yet (${earlierPending.join(', ')}) and it may depend on them. Tell the user, then apply them all in order with run_datatable_migrations.` + } + + toolCallbacks.setToolStatus(toolId, { content: `Applying migration ${label}...` }) + try { + await WorkspaceService.runDatatableMigrations({ + workspace, + datatableName, + only: version + }) + } catch (e) { + // Kept whatever failed: part of it may be applied, or all of it with only the record + // missing, and the definition with its down is then the one trace of that. + return fail( + toolCallbacks, + toolId, + `Migration ${label} is saved but failed to apply: ${apiErrorMessage(e)}. It stays pending and blocks later migrations. If nothing of it was applied, fix it by calling this tool again with version ${version}; otherwise, or to drop it, tell the user, who can edit or delete it in Workspace settings → Data Tables.` + ) + } + toolCallbacks.setToolStatus(toolId, { content: `Applied migration ${label}` }) + return `Applied migration ${label} on datatable "${datatableName}".` + } + }, + { + requires: RUN_PREVIEW, + def: getRunMigrationsToolDef(), + requiresConfirmation: true, + confirmationMessage: 'Apply pending data table migrations', + queuedLabel: (args) => `Apply migrations on "${args?.datatable_name ?? 'datatable'}"`, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const { datatable_name } = getRunMigrationsSchema().parse(args) + toolCallbacks.setToolStatus(toolId, { + content: `Applying migrations on "${datatable_name}"...` + }) + try { + const { applied } = await WorkspaceService.runDatatableMigrations({ + workspace, + datatableName: datatable_name + }) + if (applied.length === 0) { + toolCallbacks.setToolStatus(toolId, { content: 'No pending migration to apply' }) + return `No pending migration to apply on datatable "${datatable_name}".` + } + toolCallbacks.setToolStatus(toolId, { + content: `Applied ${applied.length} migration(s) on "${datatable_name}"` + }) + return `Applied ${applied.length} migration(s) on datatable "${datatable_name}": ${applied.map(migrationLabel).join(', ')}.` + } catch (e) { + // Those before the failing one are applied: the list is what says where it stopped. + return fail( + toolCallbacks, + toolId, + `Error applying migrations: ${apiErrorMessage(e)}. Migrations before the failing one may have been applied: list them to see.` + ) + } + } + }, + { + requires: RUN_PREVIEW, + def: getRollbackMigrationToolDef(), + requiresConfirmation: true, + confirmationMessage: (args) => `Roll back data table migration ${args?.version ?? ''}`, + queuedLabel: (args) => `Roll back migration ${args?.version ?? ''}`, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const { datatable_name, version } = getRollbackMigrationSchema().parse(args) + toolCallbacks.setToolStatus(toolId, { + content: `Rolling back migration ${version} on "${datatable_name}"...` + }) + try { + const { rolled_back } = await WorkspaceService.rollbackDatatableMigrations({ + workspace, + datatableName: datatable_name, + only: version + }) + if (rolled_back.length === 0) { + toolCallbacks.setToolStatus(toolId, { content: `Migration ${version} is not applied` }) + return `Migration ${version} is not applied on datatable "${datatable_name}", so there is nothing to roll back.` + } + const label = rolled_back.map(migrationLabel).join(', ') + toolCallbacks.setToolStatus(toolId, { content: `Rolled back migration ${label}` }) + return `Rolled back migration ${label} on datatable "${datatable_name}". It is pending again.` + } catch (e) { + return fail(toolCallbacks, toolId, `Error rolling back: ${apiErrorMessage(e)}`) + } + } + } + ] +} + +function migrationStatus(status: 'ran' | 'not_run' | 'unknown'): 'applied' | 'pending' | 'unknown' { + return status === 'ran' ? 'applied' : status === 'not_run' ? 'pending' : 'unknown' +} + +function withRole(sql: string, role: string | undefined): string { + return role === undefined ? sql : withMigrationRole(sql, role) +} diff --git a/frontend/src/lib/components/copilot/chat/datatableTools.ts b/frontend/src/lib/components/copilot/chat/datatableTools.ts index 7a743ab783..e05b2e4325 100644 --- a/frontend/src/lib/components/copilot/chat/datatableTools.ts +++ b/frontend/src/lib/components/copilot/chat/datatableTools.ts @@ -286,6 +286,7 @@ export function getDatatableTools(): SessionTool<{}>[] { datatable_name: d.datatable_name, schemas: d.schemas, ...(d.error && { error: d.error }), + ...(d.migrations_enabled && { migrations_enabled: true }), ...(d.permissioned && { usable_roles: d.usable_roles, default_role: d.default_role }) })), null, diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 5295712626..c935e1bc02 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -121,6 +121,7 @@ import { } from '../skills/skillResources' import { MAX_SKILL_DESCRIPTION_LENGTH, MAX_SKILL_INSTRUCTIONS_LENGTH } from '../skills/skillMd' import { + getDatatablePrompt, getDatatableSdkReference, getFlowPrompt, getPipelinePrompt, @@ -161,6 +162,7 @@ import { searchDocsTool, readDocsPageTool } from '../docs/core' import { createDbSchemaTool } from '../script/core' import type { ContextElement } from '../context' import { getDatatableTools } from '../datatableTools' +import { getDatatableMigrationTools } from '../datatableMigrationTools' import { getDucklakeTools } from '../ducklakeTools' import { fileTools } from '../files/fileTools' import type { AttachedFilesStore } from '../files/attachedFiles.svelte' @@ -363,7 +365,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum) const getInstructionsSchema = z.object({ subject: instructionSubjectSchema.describe( - 'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' + 'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' ), language: scriptLangSchema .optional() @@ -1453,6 +1455,16 @@ const buildGlobalSystemPrompt = ( ? '\n- If the user message includes an ACTIVE PREVIEW section, that is the page the side panel is showing — resolve "this page", "here" and "it" against it, and against `open` (the item the user has open in its editor) when there is one. It already tells you what get_preview_status would, so do not call that tool to learn what is on screen; call it only to check the panel\'s *other* tabs.' : '' const pipelineBullet = `- A "data pipeline" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file \`pipeline\` / \`on \` annotation comments written in each script's comment syntax (\`--\` for SQL, \`#\` for Python/Bash, \`//\` for TS — a \`//\` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject "pipeline" and build annotated script drafts — do not build a flow. list_pipelines lists the existing ones by folder.${pipelineAlphaNote}` + // Roles need a self-hosted Enterprise Edition. The edition is knowable only in the browser, + // so elsewhere the guidance stays and nothing is claimed about the instance. + const datatableRolesAvailable = !isCloudHosted() && (!BROWSER || !!get(enterpriseLicense)) + const datatableRolesBullet = datatableRolesAvailable + ? ` +- A datatable can be under roles; list_datatables then reports its \`usable_roles\` and \`default_role\`. When working on a raw app that pins a role (\`data.roles\` in its raw_app.yaml), pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can. For anything else about roles, grants or a \`permission denied\`, call get_instructions with subject "datatable".` + : BROWSER + ? ` +- Datatable roles and permissions are not available on this instance (they need a self-hosted Enterprise Edition): everyone who can use a datatable reaches all of it, so do not promise per-user, per-schema or per-row access on one.` + : '' // Hosting and edition come from the hostname and a store the app populates at init, so // they are knowable only in the browser: a non-browser caller reads false for both and // would be told "self-hosted Community Edition" whatever it targets. No base URL here @@ -1623,13 +1635,9 @@ Data Tables: - Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.${when( canRunPreview, ` -- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.` - )}${ - isCloudHosted() - ? '' - : ` -- A raw app may use a datatable through a role (\`data.roles\` in its raw_app.yaml). When working on such an app, pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can.` - }${when( +- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step. +- A datatable that list_datatables reports with \`migrations_enabled\` keeps its schema as versioned migrations: change its schema with create_datatable_migration, never exec_datatable_sql. list_datatable_migrations reads them, run_datatable_migrations applies what is pending, rollback_datatable_migration undoes one. A migration is applied for real, not drafted, so call get_instructions with subject "datatable" before your first one.` + )}${datatableRolesBullet}${when( canWriteDraft, ` - When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.` @@ -2525,7 +2533,9 @@ ${getResourcePrompt()}` function getDatatableInstructions(language?: ScriptLang): string { // Default to the TypeScript SDK so we return only what's needed, not both. const lang = language ?? 'bun' - return `# Datatable SQL SDK reference + return `${getDatatablePrompt()} + +# Datatable SQL SDK reference Datatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime. @@ -3483,7 +3493,7 @@ export const globalTools: SessionTool<{}>[] = [ def: createToolDef( getInstructionsSchema, 'get_instructions', - 'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or the datatable SQL SDK (wmill.datatable()) used inside runnables.' + 'Get authoring guidance for scripts, flows, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.' ), planModeSafe: true, fn: async (ctx) => { @@ -4773,6 +4783,7 @@ export const globalTools: SessionTool<{}>[] = [ }, // Workspace-scoped datatable tools (unrestricted: no whitelist, no creation policy) ...getDatatableTools(), + ...getDatatableMigrationTools(), // Workspace DuckLake: pipeline storage prerequisite, and declared measures ...getDucklakeTools(), // Read-only tools over files the user attached to the conversation diff --git a/system_prompts/README.md b/system_prompts/README.md index cc16473c6c..a651c48692 100644 --- a/system_prompts/README.md +++ b/system_prompts/README.md @@ -46,6 +46,7 @@ a CLI-only workflow file (`flow-cli.md`, `script-cli.md`, `raw-app-cli.md`). | resources | `getResourcePrompt()` | `resources` | `resources.md` | | workflow-as-code | `getWorkflowAsCodePrompt(lang)` | `write-workflow-as-code` | `workflow-as-code.md`, the WAC SDK | | pipeline | `getPipelinePrompt()` | `write-pipeline` | `pipeline-base.md` | +| data tables | `getDatatablePrompt()` | `datatables` | `datatable.md` | | triggers, schedules, preview, CLI commands | — | same names | CLI only | The app chat embeds `RAW_APP_BASE` directly (`chat/app/core.ts`). diff --git a/system_prompts/auto-generated/index.d.ts b/system_prompts/auto-generated/index.d.ts index d77f699a33..8ba83f7f74 100644 --- a/system_prompts/auto-generated/index.d.ts +++ b/system_prompts/auto-generated/index.d.ts @@ -4,5 +4,6 @@ export declare function getFlowPrompt(): string; export declare function getResourcePrompt(): string; export declare function getRawAppPrompt(language?: string): string; export declare function getPipelinePrompt(): string; +export declare function getDatatablePrompt(): string; export declare function getDatatableSdkReference(language?: string): string; export declare function getWorkflowAsCodePrompt(language?: string): string; diff --git a/system_prompts/auto-generated/index.ts b/system_prompts/auto-generated/index.ts index ade95efe9e..60bb79b031 100644 --- a/system_prompts/auto-generated/index.ts +++ b/system_prompts/auto-generated/index.ts @@ -73,6 +73,13 @@ export function getPipelinePrompt(): string { ].filter(Boolean).join('\n\n'); } +// Helper for data table schema changes, migrations and permissions (chat consumers) +export function getDatatablePrompt(): string { + return [ + prompts.DATATABLE_BASE + ].filter(Boolean).join('\n\n'); +} + // Helper to get the datatable SQL SDK reference (wmill.datatable()). // Pass a language to get only that SDK; omit it to get both. export function getDatatableSdkReference(language?: string): string { diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index e74555504e..cfdde52495 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -5,6 +5,7 @@ export declare const FLOW_BASE: string; export declare const RESOURCES_BASE: string; export declare const RAW_APP_BASE: string; export declare const PIPELINE_BASE: string; +export declare const DATATABLE_BASE: string; export declare const WORKFLOW_AS_CODE_BASE: string; export declare const FLOW_CHAT_SPECIAL_MODULES: string; export declare const SDK_TYPESCRIPT: string; diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index c3310351b7..54d869f727 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1338,6 +1338,47 @@ FROM ducklake.main.orders GROUP BY 1 \`\`\` `; +export const DATATABLE_BASE = `# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, \`main\` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an \`up\` and an optional \`down\`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in \`BEGIN; ... END;\` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as \`CREATE INDEX CONCURRENTLY\`: such a migration can fail half applied. +- Give it a \`down\` that undoes the \`up\` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (\`CREATE SCHEMA IF NOT EXISTS\`), and point what you are building at it. A raw app names its schema in its \`data\` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. \`admin\` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (\`wmill.datatable('main', { role: 'analyst' })\`, \`role='analyst'\` in Python, \`datatable://main?role=analyst\`) and holds that role alone, or names none and is resolved from the data table's default role; +- \`permission denied\` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to \`admin\` to get past it; +- a migration runs as the role its SQL declares on its first line, \`-- role \`, the same one in its \`down\`. One that declares none runs as \`admin\`, which only those who may use \`admin\` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. +`; + export const WORKFLOW_AS_CODE_BASE = `# Windmill Workflow-as-Code Writing Guide ## Scope diff --git a/system_prompts/auto-generated/skills/datatables/SKILL.md b/system_prompts/auto-generated/skills/datatables/SKILL.md new file mode 100644 index 0000000000..1c1cb9eede --- /dev/null +++ b/system_prompts/auto-generated/skills/datatables/SKILL.md @@ -0,0 +1,46 @@ +--- +name: datatables +description: MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions. +--- + +# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied. +- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. + +`wmill datatable migrate new ` scaffolds `migrations/datatable//_.up.sql` and `.down.sql`, `wmill datatable migrate up` pushes the local files and applies what is pending, `wmill datatable migrate down` rolls back the most recent one and `wmill datatable migrate status` lists them. All take `-d ` and default to `main`. + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role; +- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it; +- a migration runs as the role its SQL declares on its first line, `-- role `, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. diff --git a/system_prompts/base/datatable.md b/system_prompts/base/datatable.md new file mode 100644 index 0000000000..c90fb5e349 --- /dev/null +++ b/system_prompts/base/datatable.md @@ -0,0 +1,43 @@ +# Data tables: schema changes, migrations and permissions + +A data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL. + +## Changing the schema + +A data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table: + +- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it. +- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else. + +Writing a migration: + +- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied. +- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back. +- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. +- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. + + +`wmill datatable migrate new ` scaffolds `migrations/datatable//_.up.sql` and `.down.sql`, `wmill datatable migrate up` pushes the local files and applies what is pending, `wmill datatable migrate down` rolls back the most recent one and `wmill datatable migrate status` lists them. All take `-d ` and default to `main`. + + +## A migration is not a draft + +A migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use: + +- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default; +- or work in a workspace fork whose data table was cloned when the fork was created ("Clone schema only" or "Clone schema and data"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once. + +Altering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it. + +## Roles and permissions + +Data table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise. + +A data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted: + +- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants; +- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role; +- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it; +- a migration runs as the role its SQL declares on its first line, `-- role `, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do. + +Read the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles. diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 918deef0fb..055f03d543 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -1696,6 +1696,12 @@ TOPICS: dict[str, dict] = { 'chat_helper': 'getPipelinePrompt', 'parts': ['pipeline-base.md'], }, + 'datatable': { + 'skill': 'datatables', + 'description': "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions.", + 'chat_helper': 'getDatatablePrompt', + 'parts': ['datatable.md'], + }, 'cli_commands': { 'skill': 'cli-commands', 'description': 'MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`.', @@ -1715,6 +1721,7 @@ CHAT_CONSTANTS = { 'resources.md': 'RESOURCES_BASE', 'raw-app.md': 'RAW_APP_BASE', 'pipeline-base.md': 'PIPELINE_BASE', + 'datatable.md': 'DATATABLE_BASE', 'workflow-as-code.md': 'WORKFLOW_AS_CODE_BASE', 'flow-chat-special-modules.md': 'FLOW_CHAT_SPECIAL_MODULES', } @@ -2650,6 +2657,11 @@ export function getPipelinePrompt(): string { __RETURN_pipeline__ } +// Helper for data table schema changes, migrations and permissions (chat consumers) +export function getDatatablePrompt(): string { + __RETURN_datatable__ +} + // Helper to get the datatable SQL SDK reference (wmill.datatable()). // Pass a language to get only that SDK; omit it to get both. export function getDatatableSdkReference(language?: string): string { @@ -2711,6 +2723,7 @@ export declare function getFlowPrompt(): string; export declare function getResourcePrompt(): string; export declare function getRawAppPrompt(language?: string): string; export declare function getPipelinePrompt(): string; +export declare function getDatatablePrompt(): string; export declare function getDatatableSdkReference(language?: string): string; export declare function getWorkflowAsCodePrompt(language?: string): string; """ From 3d67d3ac6b24310f566047571b3a37fe4df7a431 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 14:10:23 +0200 Subject: [PATCH 04/28] refactor: mount the runs list in process in ai session previews (#11724) * refactor: mount the runs list in process in ai session previews Co-Authored-By: Claude Opus 5.5 * fix: tell runs list tabs apart by filters and reseed on re-point Co-Authored-By: Claude Opus 5.5 * fix: remount a hosted runs list only when moved from outside Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- frontend/src/lib/components/RunsPage.svelte | 76 +++++++++++-------- .../runs/BatchReRunOptionsPane.svelte | 8 +- .../lib/components/runs/JobRunsPreview.svelte | 5 +- .../src/lib/components/runs/RunRow.svelte | 7 +- .../src/lib/components/runs/RunsTable.svelte | 30 ++++++-- .../components/sessions/PageListView.svelte | 69 +++++++++++++++-- .../lib/components/sessions/previewPaths.ts | 19 ++++- .../components/sessions/previewRouter.test.ts | 17 ++++- .../lib/components/sessions/previewRouter.ts | 19 +++-- .../src/routes/(root)/(logged)/+layout.svelte | 4 +- .../(root)/(logged)/sessions/+page.svelte | 4 +- 11 files changed, 196 insertions(+), 62 deletions(-) diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 7a130869b5..3cc3bb2ffd 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -11,14 +11,12 @@ import { sendUserToast } from '$lib/toast' import { displayDate } from '$lib/utils' + import { userWorkspaces, superadmin, devopsRole, enterpriseLicense } from '$lib/stores' + import { useHostedPage } from '$lib/components/hostedPage' import { - userStore, - workspaceStore, - userWorkspaces, - superadmin, - devopsRole, - enterpriseLicense - } from '$lib/stores' + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' import { Button, Drawer, DrawerContent, Skeleton, Tab, Tabs } from '$lib/components/common' import TextInput from '$lib/components/text_input/TextInput.svelte' import RunChart from '$lib/components/RunChart.svelte' @@ -81,6 +79,13 @@ let { initialPath }: Props = $props() + // Under a host — an AI session's preview tab — the document URL, the page header band and + // the navigation workspace all belong to the host: the filters live where it keeps them, + // the filter row renders in the page, and requests go to the workspace it names. + const hosted = useHostedPage() + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + let paths: string[] = $state([]) let usernames: string[] = $state([]) let folders: string[] = $state([]) @@ -91,7 +96,7 @@ folders, jobTriggerKinds, isSuperAdminOrDevops: !!$superadmin || !!$devopsRole, - isAdminsWorkspace: $workspaceStore === 'admins' + isAdminsWorkspace: $operatingWorkspace === 'admins' }) ) let perPage = useLocalStorageValue('runs_per_page', 1000, 'number') @@ -115,6 +120,8 @@ // what was seeded at mount is gone. Re-apply it. Editing a filter writes with `replaceState`, // which never reaches `page.url`, so a filter the user clears stays cleared. $effect(() => { + // A host remounts the page when it re-points it, and its document URL is not this page's. + if (hosted) return page.url.href untrack(() => filters.seed(filterSeeds())) }) @@ -171,7 +178,9 @@ let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined) let graph: 'RunChart' | 'ConcurrencyChart' = $state( - typeOfChart(page.url.searchParams.get('graph')) + typeOfChart( + (hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph') + ) ) let innerWidth = $state(window.innerWidth) let jobsLoader = useJobsLoader(() => ({ @@ -183,7 +192,7 @@ resultError, perPage: perPage.val, lookback: graph === 'RunChart' ? 0 : lookback, - currentWorkspace: $workspaceStore ?? '' + currentWorkspace: $operatingWorkspace ?? '' })) let batchProgress = $derived(jobsLoader.batchProgress) let currentBatchSize = $derived(jobsLoader.currentBatchSize) @@ -211,18 +220,18 @@ } async function loadUsernames(): Promise { - usernames = await UserService.listUsernames({ workspace: $workspaceStore! }) + usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! }) } async function loadFolders(): Promise { folders = await FolderService.listFolders({ - workspace: $workspaceStore! + workspace: $operatingWorkspace! }).then((x) => x.map((y) => y.name)) } async function loadPaths() { - const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' }) - const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' }) + const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $operatingWorkspace ?? '' }) + const npaths_flows = await FlowService.listFlowPaths({ workspace: $operatingWorkspace ?? '' }) paths = npaths_scripts.concat(npaths_flows).sort() } @@ -265,7 +274,7 @@ const resultFilter = filters.val.result && JSON.parse(filters.val.result) const { minTs, maxTs } = timeframe.computeMinMax() return { - workspace: $workspaceStore ?? '', + workspace: $operatingWorkspace ?? '', startedBefore: maxTs ?? undefined, startedAfter: minTs ?? undefined, schedulePath: filters.val.schedule_path ?? undefined, @@ -323,7 +332,7 @@ } async function cancelJobs(uuidsToCancel: string[], forceCancel: boolean = false) { const uuids = await JobService.cancelSelection({ - workspace: $workspaceStore ?? '', + workspace: $operatingWorkspace ?? '', requestBody: uuidsToCancel, forceCancel: forceCancel, allWorkspaces: filters.val.all_workspaces || undefined @@ -350,7 +359,7 @@ for (const j of jobs ?? []) if (j.workspace_id) workspaceById.set(j.id, j.workspace_id) const byWorkspace = new Map() for (const id of jobIds) { - const ws = workspaceById.get(id) ?? $workspaceStore ?? '' + const ws = workspaceById.get(id) ?? $operatingWorkspace ?? '' const bucket = byWorkspace.get(ws) if (bucket) bucket.push(id) else byWorkspace.set(ws, [id]) @@ -428,7 +437,7 @@ } async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) { - if (!$workspaceStore) return + if (!$operatingWorkspace) return if (askingForConfirmation) { askingForConfirmation.loading = true @@ -442,7 +451,7 @@ // workaround because EventSource does not support POST requests // https://medium.com/@david.richards.tech/sse-server-sent-events-using-a-post-request-without-eventsource-1c0bd6f14425 - const response = await fetch(`${OpenAPI.BASE}/w/${$workspaceStore}/jobs/run/batch_rerun_jobs`, { + const response = await fetch(`${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) @@ -555,7 +564,7 @@ } $effect(() => { - if ($workspaceStore) { + if ($operatingWorkspace) { untrack(() => { loadUsernames() loadFolders() @@ -603,7 +612,7 @@ openedRunId = undefined } let resolutionNote = $state('') - const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR) + const phone = $derived(!hosted && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR) {#snippet runsHint()} @@ -620,7 +629,7 @@ two or three buttons, so they take a wider gap than the band's own. `flex-1 min-w-0` passes the band's flexibility down to the search field, the one control here that gives width back; the rest keep theirs or drop out of the row entirely at a width. --> -
+
-
+
RUNS_PATH + window.location.search, - workspaceId: $workspaceStore ?? undefined + workspaceId: $operatingWorkspace ?? undefined }} btnProps={{ unifiedSize: 'sm' }} /> @@ -900,13 +909,14 @@ + { - reset() + if (!hosted) reset() }} /> -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.runs} +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.runs} + {/if} + {#if showMain && showTriggersGroup}
{#if isCollapsed}
@@ -529,7 +535,7 @@ {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} {/each} - {#if extraTriggerLinks.length > 0 && allows('triggers')} + {#if showAddTriggerMenu} {#snippet triggr({ trigger })} Date: Tue, 13 Oct 2026 15:36:41 +0200 Subject: [PATCH 09/28] fix: pin the floating sidebar and let page clicks through it (#11711) * fix: let clicks through the floating sidebar's outside layer The undocked sidebar's outside-click layer swallowed the click that dismissed it, so a page button under it needed two clicks. Dismiss on a page pointerdown instead and let the press reach its target; close on Esc; make the header handle toggle. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: leave the floating sidebar open on an Esc an overlay claims Esc now waits for a drawer or menu to claim the key, skips while an overlay is on the viewport stack, ignores hidden menus, and returns focus to the handle. The hover-then-click grace on the handle is now time-bound, so a stale hover never swallows a later toggle. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: pin the floating sidebar from its handle A click on the handle pins the card, so it no longer slides away when the pointer leaves; the handle shows as selected while pinned. Another click on the handle, a press on the page, Esc or a navigation closes it. Hover still previews it unpinned. This replaces the one-second window that ignored a click right after a hover. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/PageHeaderBar.svelte | 7 +- .../sidebar/navHandlePlacement.svelte.ts | 35 +++++++- .../src/routes/(root)/(logged)/+layout.svelte | 85 +++++++++++++++---- 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 0cc47e58ca..f30a77cba5 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -106,7 +106,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba {#if (navDetached.val || panelled) && !navHidden} @@ -123,7 +124,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba iconOnly startIcon={{ icon: PanelLeft }} aria-label="Show sidebar" - onclick={() => navHandleSlot.open()} + selected={navHandleSlot.pinned} + aria-pressed={navHandleSlot.pinned} + onclick={() => navHandleSlot.toggle()} />
{/if} diff --git a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts index 5fb7b43860..8648961ef6 100644 --- a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts +++ b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts @@ -6,6 +6,13 @@ let opener: (() => void) | undefined // Closing it once the pointer is off both the handle and the card, which the layout owns because // the card is its markup. let hoverClose: { schedule: () => void; cancel: () => void } | undefined +type CardState = { + isOpen: () => boolean + isPinned: () => boolean + pin: () => void + close: () => void +} +let card: CardState | undefined // Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than // the card starting at whatever height the control that opened it happens to have. Measured at open @@ -21,10 +28,34 @@ export const navHandleSlot = { get cardTop(): number { return cardTop }, - /** The root layout owns the card, so it registers how to open and hover-close it. */ - setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) { + /** The card is open and pinned, so the handle shows as selected. Reactive. */ + get pinned(): boolean { + return !!card?.isOpen() && card.isPinned() + }, + /** The root layout owns the card, so it registers how to open, pin, close and hover-close it. */ + setOpener( + open: () => void, + close?: { schedule: () => void; cancel: () => void }, + state?: CardState + ) { opener = open hoverClose = close + card = state + }, + /** + * A click on the handle pins the card, opening it first if it is closed: a pinned card stays + * when the pointer leaves, until a press on the page, Esc, or the next click here closes it. A + * card the pointer opened is not pinned yet, so the click that follows the hover pins it — and + * a touch tap, which sends that mouseenter just before its click, pins it too. + */ + toggle(anchor?: Element | null) { + if (card?.isOpen() && card.isPinned()) { + card.close() + return + } + if (!card?.isOpen()) this.open(anchor) + hoverClose?.cancel() + card?.pin() }, /** Pointer left the handle: close, unless it lands on the card before the delay is up. */ scheduleClose() { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 868d78c73b..2dcdeab5dd 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -86,6 +86,7 @@ import { twMerge } from 'tailwind-merge' import { navDetached } from '$lib/components/sidebar/navDetached.svelte' import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte' + import { overlayStack } from '$lib/components/common/overlayHost.svelte' import PageHeaderBar from '$lib/components/PageHeaderBar.svelte' import { BAR_HEIGHT_PX, pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes' @@ -514,8 +515,11 @@ function pointerInMenu(): boolean { return !!document.querySelector('[role="menu"]:hover') } + // A click on the handle pins the card: it then outlives the pointer leaving. Only meaningful + // while open — every open from closed starts unpinned. + let menuPinned = $state(false) const { debounced: schedulePeekClose, clearDebounce: cancelPeekClose } = debounce(() => { - if (!peeksOnHover) return + if (!peeksOnHover || menuPinned) return if (pointerInMenu()) { schedulePeekClose() return @@ -528,10 +532,56 @@ const floatBand = $derived(pageHeader.content?.barRightInset != null) // The handle and the edge band both open the card this layout owns. - navHandleSlot.setOpener(() => (menuOpen = true), { - schedule: schedulePeekClose, - cancel: cancelPeekClose - }) + navHandleSlot.setOpener( + () => { + if (!menuOpen) menuPinned = false + menuOpen = true + }, + { schedule: schedulePeekClose, cancel: cancelPeekClose }, + { + isOpen: () => menuOpen, + isPinned: () => menuPinned, + pin: () => (menuPinned = true), + close: () => (menuOpen = false) + } + ) + + // The open card is dismissed by any press on the page, and that press still reaches its target: + // a layer that caught it would cost every dismissal a second click. Only presses inside the page + // column count — the card itself and the menus it portals to `body` sit outside it — and the + // handle toggles the card itself. Capture phase, so a target that stops propagation still closes it. + let pageColumn: HTMLElement | undefined = $state() + function closeCardOnPagePress(e: PointerEvent) { + if (!menuOpen || !useDrawer) return + const target = e.target + if (!(target instanceof Element) || !pageColumn?.contains(target)) return + if (target.closest('[data-nav-handle]')) return + menuOpen = false + } + // A press inside an iframe (a session's preview) never reaches this window; focus moving into one does. + function closeCardOnFrameFocus() { + if (!menuOpen || !useDrawer) return + setTimeout(() => { + if (document.activeElement instanceof HTMLIFrameElement) menuOpen = false + }) + } + + const viewportOverlays = overlayStack() + function onWindowKeydown(e: KeyboardEvent) { + onSearchShortcutBeforeLoad(e) + if (e.key !== 'Escape' || !menuOpen || !useDrawer) return + // Read now: the overlay this Esc closes is off the stack by the time the timeout runs. + if (viewportOverlays.val.length > 0) return + // This listener is attached before any drawer's or menu's, which mount later: wait for + // them, so an Esc that closes one of those leaves the card open. + setTimeout(() => { + if (e.defaultPrevented || document.querySelector('[role="menu"]:not([hidden])')) return + const focusInCard = !!document.activeElement?.closest('[data-nav-card]') + menuOpen = false + // The closed card is inert and would drop focus to `body`. + if (focusInCard) document.querySelector('[data-nav-handle] button')?.focus() + }) + } // Set once the user docks or detaches, so the rail that mounts from then on skips its // app-entry animation (`wm-sidebar-in`) and only plays `railMorph`. @@ -1041,7 +1091,12 @@ }) - + - +
{ - if (e.target === e.currentTarget) menuOpen = false - }} >
@@ -1416,7 +1469,7 @@
{/if} {/if} -
+
From c7df0093d0d9a38362222b7350bf387c31af9392 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 13 Oct 2026 15:36:59 +0200 Subject: [PATCH 10/28] fix: stop marking run form fields as disabled for assistive tech (#11714) Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/lib/components/SchemaForm.svelte | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index d05c3cce72..d761101d76 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -300,7 +300,14 @@
From a3653593f2b064b762a19a1d4294988c27baaec0 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:37:15 +0200 Subject: [PATCH 11/28] fix: keep the resource type search current between daily fills, and serve it without an index (#11712) Claude-Session: https://claude.ai/code/session_015YR13nJWziUSWktZwhC7dx Co-authored-by: Claude Opus 5.5 --- backend/windmill-api-embeddings/src/lib.rs | 390 ++++++++++++++---- .../tests/resources.rs | 59 +++ 2 files changed, 371 insertions(+), 78 deletions(-) diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 6e4d6a1c71..70528491e8 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -9,16 +9,11 @@ use windmill_common::DEFAULT_HUB_BASE_URL; #[cfg(feature = "embedding")] use windmill_common::HUB_BASE_URL; -use axum::Router; - -#[cfg(feature = "embedding")] use axum::{ extract::{Path, Query}, - Json, + routing::get, + Extension, Json, Router, }; - -#[cfg(feature = "embedding")] -use axum::routing::get; #[cfg(feature = "embedding")] use candle_core::{Device, Tensor}; #[cfg(feature = "embedding")] @@ -27,10 +22,7 @@ use candle_nn::VarBuilder; use candle_transformers::models::bert::{BertModel, Config, DTYPE}; #[cfg(feature = "embedding")] use hf_hub::api::tokio::Api; -#[cfg(feature = "embedding")] -use serde::Deserialize; -#[cfg(feature = "embedding")] -use serde::Serialize; +use serde::{Deserialize, Serialize}; #[cfg(feature = "embedding")] use sqlx::{Pool, Postgres}; #[cfg(feature = "embedding")] @@ -44,9 +36,7 @@ use tokenizers::Tokenizer; use tokio::sync::RwLock; #[cfg(feature = "embedding")] use windmill_common::utils::http_get_from_hub; - -#[cfg(feature = "embedding")] -use windmill_common::error::JsonResult; +use windmill_common::{db::DB, error::JsonResult}; #[cfg(feature = "embedding")] use windmill_store::resources::ResourceType; @@ -60,6 +50,7 @@ lazy_static::lazy_static! { // full pulling interval, so a transient startup error doesn't leave the // embeddings DB uninitialized for a whole day. pub static ref HUB_EMBEDDINGS_RETRY_INTERVAL_SECS: u64 = std::env::var("HUB_EMBEDDINGS_RETRY_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(60); + pub static ref RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS: u64 = std::env::var("RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(60); } #[cfg(feature = "embedding")] @@ -104,14 +95,12 @@ async fn query_hub_scripts( } } -#[cfg(feature = "embedding")] #[derive(Deserialize)] struct ResourceTypesQuery { text: String, limit: Option, } -#[cfg(feature = "embedding")] #[derive(Serialize)] pub struct ResourceTypeResult { name: String, @@ -138,24 +127,89 @@ fn trim_to_top_score( .take_while(|r| (top_score - score(r)) / top_score <= max_relative_drop) .collect() } -#[cfg(feature = "embedding")] async fn query_resource_types( + Extension(db): Extension, Query(query): Query, Path(w_id): Path, ) -> JsonResult> { - let embeddings_db = EMBEDDINGS_DB.read().await; - - if let Some(embeddings_db) = embeddings_db.as_ref() { - let results = embeddings_db - .query_resource_types(w_id, &query.text, query.limit) - .await?; - - Ok(Json(results)) - } else { - Err(windmill_common::error::Error::internal_err( - "Embeddings db not initialized".to_string(), - )) + #[cfg(feature = "embedding")] + if let Some(embeddings_db) = EMBEDDINGS_DB.read().await.as_ref() { + return Ok(Json( + embeddings_db + .query_resource_types(w_id, &query.text, query.limit) + .await?, + )); } + Ok(Json( + match_resource_types_by_text(&db, &w_id, &query.text, query.limit).await?, + )) +} + +/// Serves the search when there is no embeddings index: a build without the `embedding` +/// feature, or one whose index never loaded (air-gapped, `DISABLE_EMBEDDING`). The AI chat +/// has no other way to read the instance's resource types. +async fn match_resource_types_by_text( + db: &DB, + w_id: &str, + text: &str, + limit: Option, +) -> windmill_common::error::Result> { + let words: Vec = text + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect(); + let rows: Vec<(String, Option, Option)> = sqlx::query_as( + "SELECT name, description, schema FROM resource_type WHERE workspace_id = $1 OR workspace_id = 'admins'", + ) + .bind(w_id) + .fetch_all(db) + .await?; + + let mut results: Vec = rows + .into_iter() + .map(|(name, description, schema)| ResourceTypeResult { + score: text_match_score(&words, &name, description.as_deref().unwrap_or_default()), + name, + schema, + }) + .filter(|rt| rt.score > 0.0) + .collect(); + results.sort_by(|a, b| { + b.score + .total_cmp(&a.score) + .then_with(|| a.name.cmp(&b.name)) + }); + results.truncate(limit.unwrap_or(10).max(0) as usize); + Ok(results) +} + +/// A word found in the name counts twice a word found in the description: names are what +/// a query for a service spells out ("postgres" in `postgresql`, "sheets" in `gsheets`). +/// A word under 3 characters ("s3", "ai") would sit inside too many names, so it counts only +/// as a whole name or one of its `_`-separated parts. +fn text_match_score(words: &[String], name: &str, description: &str) -> f32 { + let name = name.to_lowercase(); + let description = description.to_lowercase(); + words + .iter() + .map(|word| { + let short = word.len() < 3; + let in_name = if short { + name.split('_').any(|part| part == word) + } else { + name.contains(word.as_str()) + }; + if in_name { + 2.0 + } else if !short && description.contains(word.as_str()) { + 1.0 + } else { + 0.0 + } + }) + .sum() } #[cfg(feature = "embedding")] @@ -288,17 +342,60 @@ impl ModelInstance { pub struct EmbeddingsDb { db: Db, model_instance: Arc, + /// Kept from the last full fill, so the resource types can be re-indexed between fills + /// without downloading the hub's embeddings again. + hub_resource_type_embeddings: Arc>>, + /// The `resource_types_fingerprint` the resource types collection was built from. + resource_types_fingerprint: Option, + /// See `ResourceTypeIndex::custom_vectors`. + custom_resource_type_vectors: Arc>>, +} + +/// A built resource types collection, with what a later refresh needs to tell it is stale and +/// to reuse its vectors. +#[cfg(feature = "embedding")] +struct ResourceTypeIndex { + fingerprint: Option, + embeddings: Vec, + /// Vectors of the types the hub has none for, by the `name;description` they embed: a + /// refresh runs the model only on types that are new or changed, not on every custom type. + custom_vectors: Arc>>, } #[cfg(feature = "embedding")] impl EmbeddingsDb { + fn empty(model_instance: Arc) -> Self { + Self { + db: Db::new(), + model_instance, + hub_resource_type_embeddings: Arc::new(HashMap::new()), + resource_types_fingerprint: None, + custom_resource_type_vectors: Arc::new(HashMap::new()), + } + } + pub async fn new(pg_db: &Pool, model_instance: Arc) -> Result { - let db = Db::new(); - - let mut embeddings_db = Self { db, model_instance: model_instance.clone() }; - + let mut embeddings_db = Self::empty(model_instance); embeddings_db.fill_db(pg_db).await?; + Ok(embeddings_db) + } + /// The resource types alone, all embedded locally, for when the full fill cannot download + /// the hub's embeddings: an air-gapped instance, or a private hub serving none. Hub script + /// search keeps failing, since it has no collection to search. + async fn resource_types_only( + pg_db: &Pool, + model_instance: Arc, + ) -> Result { + let mut embeddings_db = Self::empty(model_instance); + let index = resource_type_index( + pg_db, + &embeddings_db.model_instance, + &embeddings_db.hub_resource_type_embeddings, + &embeddings_db.custom_resource_type_vectors, + ) + .await?; + embeddings_db.replace_resource_types(index)?; Ok(embeddings_db) } @@ -310,13 +407,6 @@ impl EmbeddingsDb { self.db .create_collection("scripts".to_string(), 384, Distance::Cosine)?; - if self.db.get_collection("resource_types").is_some() { - self.db.delete_collection("resource_types")?; - } - - self.db - .create_collection("resource_types".to_string(), 384, Distance::Cosine)?; - let hub_base_url = (**HUB_BASE_URL.load()).clone(); let response = match hub_base_url.as_str() { @@ -425,44 +515,35 @@ impl EmbeddingsDb { )); } let hub_resource_types = response.json::>().await?; + self.hub_resource_type_embeddings = Arc::new( + hub_resource_types + .into_iter() + .map(|rt| (rt.name, rt.embedding)) + .collect(), + ); - let resource_types: Vec = - sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) - .fetch_all(pg_db) - .await?; - - for rt in resource_types { - let mut hm = HashMap::new(); - hm.insert("name".to_string(), rt.name.clone()); - if let Some(schema) = rt.schema.clone() { - hm.insert("schema".to_string(), serde_json::to_string(&schema)?); - } - hm.insert("workspace".to_string(), rt.workspace_id.clone()); - let hub_rt = hub_resource_types.iter().find(|hrt| hrt.name == rt.name); - - let vector = if let Some(hub_rt) = hub_rt { - hub_rt.embedding.clone() - } else { - self.model_instance - .clone() - .create_embedding(&format!( - "{};{}", - rt.name, - rt.description.unwrap_or_default() - )) - .await? - }; - - let embedding = Embedding { - id: format!("{}_{}", rt.workspace_id, rt.name), - vector, - metadata: Some(hm), - }; + let index = resource_type_index( + pg_db, + &self.model_instance, + &self.hub_resource_type_embeddings, + &self.custom_resource_type_vectors, + ) + .await?; + self.replace_resource_types(index) + } + fn replace_resource_types(&mut self, index: ResourceTypeIndex) -> Result<()> { + if self.db.get_collection("resource_types").is_some() { + self.db.delete_collection("resource_types")?; + } + self.db + .create_collection("resource_types".to_string(), 384, Distance::Cosine)?; + for embedding in index.embeddings { self.db .insert_into_collection("resource_types", embedding)?; } - + self.resource_types_fingerprint = index.fingerprint; + self.custom_resource_type_vectors = index.custom_vectors; Ok(()) } @@ -617,6 +698,95 @@ impl EmbeddingsDb { } } +/// Covers everything an indexed resource type is built from, whichever path wrote it: the +/// create and update routes, a hub sync's direct upserts, a delete. +/// +/// A sum of per-row hashes needs no sort. Don't go back to an ORDER BY aggregate: sorting +/// every schema spills to disk once they exceed work_mem, on every check of every server. +#[cfg(feature = "embedding")] +async fn resource_types_fingerprint(pg_db: &Pool) -> Result> { + Ok(sqlx::query_scalar( + "SELECT count(*)::text || ':' || coalesce(sum(('x' || left(md5(workspace_id || '/' || name || '/' || coalesce(description, '') || '/' || coalesce(schema::text, '')), 15))::bit(60)::bigint), 0)::text FROM resource_type", + ) + .fetch_one(pg_db) + .await?) +} + +/// The fingerprint is read before the rows: a write landing in between leaves it stale, so +/// the next check indexes again rather than keeping an index that misses the write. +#[cfg(feature = "embedding")] +async fn resource_type_index( + pg_db: &Pool, + model_instance: &Arc, + hub_embeddings: &HashMap>, + known_custom_vectors: &HashMap>, +) -> Result { + let fingerprint = resource_types_fingerprint(pg_db).await?; + let resource_types: Vec = + sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) + .fetch_all(pg_db) + .await?; + + let mut embeddings = Vec::with_capacity(resource_types.len()); + let mut custom_vectors = HashMap::new(); + for rt in resource_types { + let mut hm = HashMap::new(); + hm.insert("name".to_string(), rt.name.clone()); + if let Some(schema) = rt.schema.clone() { + hm.insert("schema".to_string(), serde_json::to_string(&schema)?); + } + hm.insert("workspace".to_string(), rt.workspace_id.clone()); + + let vector = match hub_embeddings.get(&rt.name) { + Some(vector) => vector.clone(), + None => { + let text = format!("{};{}", rt.name, rt.description.unwrap_or_default()); + let vector = match known_custom_vectors + .get(&text) + .or_else(|| custom_vectors.get(&text)) + { + Some(vector) => vector.clone(), + None => model_instance.clone().create_embedding(&text).await?, + }; + custom_vectors.insert(text, vector.clone()); + vector + } + }; + + embeddings.push(Embedding { + id: format!("{}_{}", rt.workspace_id, rt.name), + vector, + metadata: Some(hm), + }); + } + Ok(ResourceTypeIndex { fingerprint, embeddings, custom_vectors: Arc::new(custom_vectors) }) +} + +/// The full fill runs once a day, so a resource type created or synced in between would stay +/// unsearchable until then; this re-indexes the resource types alone when the table changed. +#[cfg(feature = "embedding")] +pub async fn refresh_resource_type_embeddings(pg_db: &Pool) -> Result<()> { + if EMBEDDINGS_DB.read().await.is_none() { + return Ok(()); + } + let fingerprint = resource_types_fingerprint(pg_db).await?; + let (model_instance, hub_embeddings, custom_vectors) = match EMBEDDINGS_DB.read().await.as_ref() + { + Some(db) if db.resource_types_fingerprint != fingerprint => ( + db.model_instance.clone(), + db.hub_resource_type_embeddings.clone(), + db.custom_resource_type_vectors.clone(), + ), + _ => return Ok(()), + }; + let index = + resource_type_index(pg_db, &model_instance, &hub_embeddings, &custom_vectors).await?; + if let Some(db) = EMBEDDINGS_DB.write().await.as_mut() { + db.replace_resource_types(index)?; + } + Ok(()) +} + #[cfg(feature = "embedding")] fn normalize_l2(v: &Tensor) -> Result { Ok(v.broadcast_div(&v.sqr()?.sum_keepdim(1)?.sqrt()?)?) @@ -630,6 +800,19 @@ pub fn load_embeddings_db(db: &Pool) -> () { .unwrap_or(false); if !disable_embedding { + let resource_types_db = db.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs( + *RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS, + )) + .await; + if let Err(e) = refresh_resource_type_embeddings(&resource_types_db).await { + tracing::warn!("Failed to refresh resource type embeddings: {e:#}"); + } + } + }); + let db_clone = db.clone(); tokio::spawn(async move { // Keep retrying model init: a transient failure here must not @@ -664,6 +847,9 @@ pub fn load_embeddings_db(db: &Pool) -> () { backoff_secs = *HUB_EMBEDDINGS_RETRY_INTERVAL_SECS; *HUB_EMBEDDINGS_PULLING_INTERVAL_SECS } else { + if let Err(e) = index_resource_types_alone(&db_clone).await { + tracing::warn!("Failed to index the resource types alone: {e:#}"); + } let secs = backoff_secs; backoff_secs = backoff_secs .saturating_mul(2) @@ -696,7 +882,29 @@ pub async fn update_embeddings_db(db: &Pool) -> bool { } } +/// After a failed full fill, so a fill still downloading at startup is not raced. A full fill +/// that succeeds later replaces this index. #[cfg(feature = "embedding")] +async fn index_resource_types_alone(pg_db: &Pool) -> Result<()> { + if EMBEDDINGS_DB.read().await.is_some() { + return Ok(()); + } + let Some(model_instance) = MODEL_INSTANCE.read().await.clone() else { + return Ok(()); + }; + let started = std::time::Instant::now(); + let embeddings_db = EmbeddingsDb::resource_types_only(pg_db, model_instance).await?; + let mut current = EMBEDDINGS_DB.write().await; + if current.is_none() { + *current = Some(embeddings_db); + tracing::info!( + "Indexed the resource types alone in {:?}, without the hub's embeddings", + started.elapsed() + ); + } + Ok(()) +} + pub fn workspaced_service() -> Router { Router::new().route("/query_resource_types", get(query_resource_types)) } @@ -706,11 +914,6 @@ pub fn global_service() -> Router { Router::new().route("/query_hub_scripts", get(query_hub_scripts)) } -#[cfg(not(feature = "embedding"))] -pub fn workspaced_service() -> Router { - Router::new() -} - #[cfg(not(feature = "embedding"))] pub fn global_service() -> Router { Router::new() @@ -768,3 +971,34 @@ mod tests { assert_eq!(trim_to_top_score(vec![0.42f32], 0.05, |s| *s), vec![0.42]); } } + +#[cfg(test)] +mod text_match_tests { + use super::text_match_score; + + // A query names the service, which a type's name only contains ("postgres" in + // `postgresql`): an exact comparison finds nothing for "postgres database". + #[test] + fn ranks_a_name_match_above_a_description_match() { + let words = vec!["postgres".to_string(), "database".to_string()]; + assert_eq!( + text_match_score(&words, "PostgreSQL", "PostgreSQL connection"), + 2.0 + ); + assert_eq!( + text_match_score(&words, "mysql", "MySQL database credentials"), + 1.0 + ); + assert_eq!(text_match_score(&words, "slack", "Slack bot token"), 0.0); + } + + // Many services go by a short name; matching it inside other names would rank `ms365` + // with `s3`. + #[test] + fn matches_a_short_word_only_as_a_whole_name_part() { + let words = vec!["s3".to_string()]; + assert_eq!(text_match_score(&words, "s3", "Amazon S3 bucket"), 2.0); + assert_eq!(text_match_score(&words, "aws_s3", ""), 2.0); + assert_eq!(text_match_score(&words, "ms365", "mentions s3"), 0.0); + } +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 9c167ef70c..823c415818 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -751,6 +751,65 @@ async fn test_resource_cache_handles_job_context(db: Pool) -> anyhow:: Ok(()) } +/// Without an embeddings index the resource type search reads the table, so it has to apply the +/// same scope as the index: the workspace's own types and the global ones in `admins`, never +/// another workspace's. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_resource_type_search_without_embeddings(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')") + .execute(&db) + .await?; + for (workspace, name, description) in [ + ( + "test-workspace", + "c_acme_billing", + "Acme internal billing API", + ), + ( + "other-workspace", + "c_other_billing", + "Another workspace's billing API", + ), + ] { + sqlx::query( + "INSERT INTO resource_type (workspace_id, name, schema, description, created_by) VALUES ($1, $2, '{}'::jsonb, $3, 'test-user')", + ) + .bind(workspace) + .bind(name) + .bind(description) + .execute(&db) + .await?; + } + + let search = |text: &str| { + let url = format!( + "http://localhost:{port}/api/w/test-workspace/embeddings/query_resource_types?text={text}" + ); + async move { + let resp = authed(client().get(url)).send().await.unwrap(); + assert_eq!(resp.status(), 200); + resp.json::>() + .await + .unwrap() + .into_iter() + .map(|rt| rt["name"].as_str().unwrap().to_string()) + .collect::>() + } + }; + + let billing = search("billing").await; + assert_eq!(billing.first().map(String::as_str), Some("c_acme_billing")); + assert!(!billing.contains(&"c_other_billing".to_string())); + // `ai_skill` is seeded in `admins` by the migrations. + assert!(search("skill").await.contains(&"ai_skill".to_string())); + + Ok(()) +} + #[cfg(feature = "mcp")] #[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))] async fn test_mcp_tools(db: Pool) -> anyhow::Result<()> { From 3e43e6f06fce520a2a219cd29bb2c3c6083d7dd9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:37:32 +0200 Subject: [PATCH 12/28] oauth: add hubspot provider (#11695) * oauth: add hubspot provider Registry entry (authorize + token URL, body credentials, least-privilege default scopes, labelled scope_options) and the admin OAuth tile. Co-Authored-By: Claude Opus 5.5 * oauth: send hubspot non-default scopes as optional_scope Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/oauth_connect.json | 54 +++++++++++++++++++ .../src/lib/components/AuthSettings.svelte | 3 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 3c4de3b082..ceabe29145 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -445,5 +445,59 @@ "mailboxes.read" ], "req_body_auth": true + }, + "hubspot": { + "auth_url": "https://app.hubspot.com/oauth/authorize", + "token_url": "https://api.hubapi.com/oauth/v1/token", + "scopes": [ + "oauth", + "crm.objects.contacts.read", + "crm.objects.contacts.write", + "crm.objects.companies.read", + "crm.objects.companies.write", + "crm.objects.deals.read", + "crm.objects.deals.write", + "crm.objects.owners.read" + ], + "scope_options": [ + { "value": "oauth", "label": "Basic OAuth access (always required by HubSpot)" }, + { "value": "crm.objects.contacts.read", "label": "Read contacts, and notes, tasks, meetings and calls" }, + { "value": "crm.objects.contacts.write", "label": "Create and edit contacts, and notes, tasks, meetings and calls" }, + { "value": "crm.objects.companies.read", "label": "Read companies" }, + { "value": "crm.objects.companies.write", "label": "Create and edit companies" }, + { "value": "crm.objects.deals.read", "label": "Read deals and deal pipelines" }, + { "value": "crm.objects.deals.write", "label": "Create and edit deals" }, + { "value": "crm.objects.owners.read", "label": "Read owners (users assignable to records)" }, + { "value": "tickets", "label": "Read and edit tickets and ticket pipelines" }, + { "value": "crm.objects.line_items.read", "label": "Read line items" }, + { "value": "crm.objects.line_items.write", "label": "Create and edit line items" }, + { "value": "e-commerce", "label": "Read and edit products and line items" }, + { "value": "crm.objects.quotes.read", "label": "Read quotes" }, + { "value": "crm.objects.quotes.write", "label": "Create and edit quotes" }, + { "value": "crm.objects.leads.read", "label": "Read leads" }, + { "value": "crm.objects.leads.write", "label": "Create and edit leads" }, + { "value": "crm.objects.custom.read", "label": "Read custom object records" }, + { "value": "crm.objects.custom.write", "label": "Create and edit custom object records" }, + { "value": "crm.schemas.custom.read", "label": "Read custom object schemas" }, + { "value": "crm.lists.read", "label": "Read lists (segments)" }, + { "value": "crm.lists.write", "label": "Edit lists and their members" }, + { "value": "sales-email-read", "label": "Read the content of logged one-to-one emails" }, + { "value": "forms", "label": "Read and edit forms and their submissions" }, + { "value": "files", "label": "Read and upload files in the file manager" }, + { "value": "content", "label": "Read and edit marketing emails, blog posts, pages and templates" }, + { "value": "crm.objects.marketing_events.read", "label": "Read marketing events" }, + { "value": "marketing.campaigns.read", "label": "Read marketing campaigns (Marketing Hub Professional)" }, + { "value": "communication_preferences.read_write", "label": "Read and change email subscription preferences" }, + { "value": "automation", "label": "Read, create and enroll into workflows (Professional)" }, + { "value": "automation.sequences.read", "label": "Read sequences and sequence enrollments (Sales Hub Professional)" }, + { "value": "automation.sequences.enrollments.write", "label": "Enroll contacts in sequences (Sales Hub Professional)" }, + { "value": "conversations.read", "label": "Read inboxes, threads and messages" }, + { "value": "conversations.write", "label": "Send messages and update threads" }, + { "value": "scheduler.meetings.meeting-link.read", "label": "Read meeting links and their availability" } + ], + "extra_params": { + "optional_scope": "tickets crm.objects.line_items.read crm.objects.line_items.write e-commerce crm.objects.quotes.read crm.objects.quotes.write crm.objects.leads.read crm.objects.leads.write crm.objects.custom.read crm.objects.custom.write crm.schemas.custom.read crm.lists.read crm.lists.write sales-email-read forms files content crm.objects.marketing_events.read marketing.campaigns.read communication_preferences.read_write automation automation.sequences.read automation.sequences.enrollments.write conversations.read conversations.write scheduler.meetings.meeting-link.read" + }, + "req_body_auth": true } } diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index f211650df2..d1dd5fa08b 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -97,7 +97,8 @@ 'docusign', 'salesforce', 'outreach', - 'notion' + 'notion', + 'hubspot' ] // Providers whose registry entry (`backend/oauth_connect.json`) carries a // `sandbox` URL block. Each one gets a sibling `_sandbox` dropdown From d8ac4f59c8d570777e43a7aa7ffbc97598897cc2 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:40:24 +0200 Subject: [PATCH 13/28] fix: surface oauth provider errors returned with http 200 (#11713) * fix: bump git sync hub scripts to cli 1.830.0 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011BzU9zd53JsTShCsbKzYzB * fix: surface oauth provider errors returned with http 200 Co-Authored-By: Claude Opus 5.5 * chore: update ee repo ref Co-Authored-By: Claude Opus 5.5 * fix: show the unavailable sign-in option in the oauth resource form Co-Authored-By: Claude Opus 5.5 * fix: name the oauth grant in both authentication card descriptions Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 0a7c28718872bb5cd624d77e32ad7d75c6ce42de This commit updates the EE repository reference after PR #874 was merged in windmill-ee-private. Previous ee-repo-ref: 11425053010c3cd4670bc06f3606fa18cccc2091 New ee-repo-ref: 0a7c28718872bb5cd624d77e32ad7d75c6ce42de Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-oauth/src/lib.rs | 32 ++++++++++++++++++- .../src/lib/components/AppConnectInner.svelte | 32 ++++++++++++------- .../common/radioCard/RadioCard.svelte | 6 ++-- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 695f6d248b..d68805fb8a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -40ab35596e6fcb82a4dfc56e186befb807f8e2af +0a7c28718872bb5cd624d77e32ad7d75c6ce42de diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 15f5ac6b9e..9768911813 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -790,7 +790,26 @@ pub async fn exchange_code( .with_client(http_client) .execute::() .await - .map_err(|e| error::Error::InternalErr(format!("{:?}", e))) + .map_err(|e| error::Error::InternalErr(token_error_message(&e))) +} + +/// Message for a failed token request. Some providers (Zoho) answer an OAuth error with +/// HTTP 200, which the client reports as an unparseable token response, not an error one. +pub fn token_error_message(e: &ExecuteError) -> String { + match e { + ExecuteError::ErrorResponse { error, .. } => error.to_string(), + ExecuteError::BadResponse { status, body, .. } => { + serde_json::from_slice::(body) + .map(|r| r.to_string()) + .unwrap_or_else(|_| { + format!( + "unexpected response ({status}): {}", + String::from_utf8_lossy(body) + ) + }) + } + e => format!("{e:?}"), + } } /// Internal token exchange implementation @@ -1212,6 +1231,17 @@ mod tests { assert!(verifier.verify("123", "body", "wrong_sig").is_err()); } + #[test] + fn token_error_message_reads_an_error_sent_with_http_200() { + let body: &[u8] = br#"{"error":"invalid_client"}"#; + let e = ExecuteError::BadResponse { + status: reqwest::StatusCode::OK, + body: body.into(), + error: serde_json::from_slice::(body).unwrap_err(), + }; + assert_eq!(token_error_message(&e), "invalid_client"); + } + // Sage Intacct's token endpoint rejects HTTP Basic client authentication on the // refresh_token grant (`invalid_client`), so its credentials must go in the form body. #[test] diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 56bff89d8f..18a28ec95d 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -322,6 +322,15 @@ (registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false)) ) + /** The provider also has a browser sign-in, unavailable here only because the + * instance holds no OAuth client for it */ + let signInNeedsInstanceClient = $derived.by( + () => + (registryEntry()?.grant_types?.includes('authorization_code') ?? false) && + connectClient != '' && + !(connects?.includes(connectClient) ?? false) + ) + /** Clear CC inputs and scopes so a previous selection never leaks into a new one */ function resetClientCredentialsState() { supportsClientCredentials = false @@ -1399,11 +1408,7 @@
{#if apiTokenApps[resourceType].img}
- connect + connect
{/if} {/if} @@ -1514,14 +1519,14 @@ {#if supportsClientCredentials}

Authentication

- {#if ccOnly || ccBringYourOwn} + {#if (ccOnly || ccBringYourOwn) && !signInNeedsInstanceClient}
{#if useSharedInstanceCreds} {resourceType} connects server-to-server using the credentials configured for this - instance. The token is acquired and refreshed automatically. + instance. {:else} - {resourceType} connects server-to-server. Enter a client ID and secret; the token is - acquired and refreshed automatically. + {resourceType} connects server-to-server with the client credentials grant. Enter a + client ID and secret. {/if}
{:else} @@ -1534,17 +1539,20 @@ > enableClientCredentials()} /> diff --git a/frontend/src/lib/components/common/radioCard/RadioCard.svelte b/frontend/src/lib/components/common/radioCard/RadioCard.svelte index f9b7f675a4..e152324fce 100644 --- a/frontend/src/lib/components/common/radioCard/RadioCard.svelte +++ b/frontend/src/lib/components/common/radioCard/RadioCard.svelte @@ -51,8 +51,10 @@ // (FileExplorer, TriggersTable, RunnableRow, ArtifactVersionPicker). selected ? 'border-border-selected bg-surface-accent-selected' - : 'border-border-light hover:bg-surface-hover', - disabled && 'opacity-50 cursor-not-allowed', + : 'border-border-light enabled:hover:bg-surface-hover', + // `disabled:` variants, not plain utilities: the global `button:disabled` + // rule sets opacity and cursor and outranks a single class. + 'disabled:opacity-50 disabled:cursor-not-allowed', className )} > From f629df33829fa65992284585c3f93a1c0422737c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:52:01 +0200 Subject: [PATCH 14/28] oauth: add outlook (Microsoft Graph) provider (#11694) * oauth: add outlook (Microsoft Graph) provider Co-Authored-By: Claude Opus 5.5 * oauth: outlook defaults cover mail, calendar and contacts Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- backend/oauth_connect.json | 31 ++++++ .../lib/components/icons/OutlookIcon.svelte | 94 +++++++++++++++++++ frontend/src/lib/components/icons/index.ts | 3 + 3 files changed, 128 insertions(+) create mode 100644 frontend/src/lib/components/icons/OutlookIcon.svelte diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index ceabe29145..b5ebd80e2a 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -446,6 +446,37 @@ ], "req_body_auth": true }, + "outlook": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Mail.Send", "label": "Send mail" }, + { "value": "Mail.Read", "label": "Read and search mail" }, + { "value": "Mail.ReadWrite", "label": "Read, draft, move and modify mail" }, + { "value": "MailboxSettings.Read", "label": "Read mailbox settings and categories" }, + { "value": "Contacts.Read", "label": "Read contacts" }, + { "value": "Contacts.ReadWrite", "label": "Read, create and edit contacts" }, + { "value": "People.Read", "label": "Search relevant people" }, + { "value": "Calendars.Read", "label": "Read calendars and events" }, + { "value": "Calendars.ReadWrite", "label": "Read, create, edit and delete events" }, + { "value": "Calendars.Read.Shared", "label": "Read shared calendars and find meeting times" }, + { "value": "Mail.Send.Shared", "label": "Send mail from shared mailboxes" }, + { "value": "Mail.Read.Shared", "label": "Read mail in shared mailboxes" }, + { "value": "Mail.ReadWrite.Shared", "label": "Read and modify mail in shared mailboxes" } + ], + "connect_config_template": { + "display_name": "Outlook", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"] + } + }, "hubspot": { "auth_url": "https://app.hubspot.com/oauth/authorize", "token_url": "https://api.hubapi.com/oauth/v1/token", diff --git a/frontend/src/lib/components/icons/OutlookIcon.svelte b/frontend/src/lib/components/icons/OutlookIcon.svelte new file mode 100644 index 0000000000..a9cec0cfa6 --- /dev/null +++ b/frontend/src/lib/components/icons/OutlookIcon.svelte @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index bbefc16469..2b63491c6c 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -90,6 +90,7 @@ import ResendIcon from './ResendIcon.svelte' import PushoverIcon from './PushoverIcon.svelte' import QuickbooksIcon from './QuickbooksIcon.svelte' import MsTeamsIcon from './MSTeamsIcon.svelte' +import OutlookIcon from './OutlookIcon.svelte' import MailgunIcon from './MailgunIcon.svelte' import IpinfoIcon from './IpinfoIcon.svelte' import GoogleCloudIcon from './GoogleCloudIcon.svelte' @@ -445,6 +446,7 @@ export const APP_TO_ICON_COMPONENT = { quickbooks: QuickbooksIcon, ms_teams_webhook: MsTeamsIcon, teams: MsTeamsIcon, + outlook: OutlookIcon, mailgun: MailgunIcon, ipinfo: IpinfoIcon, gworkspace: GoogleIcon, @@ -776,6 +778,7 @@ export { PushoverIcon, QuickbooksIcon, MsTeamsIcon, + OutlookIcon, MailgunIcon, IpinfoIcon, GoogleCloudIcon, From 4bc2394322f957e5cf212fa1913b2b39c1b9ca06 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:52:55 +0200 Subject: [PATCH 15/28] fix: resolve variables in the url and headers of an mcp resource (#11680) * fix(cli): count superadmins as able to preserve ownership on push Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011BzU9zd53JsTShCsbKzYzB * fix: resolve variables in the url and headers of an mcp resource Co-Authored-By: Claude Opus 5.5 * fix: keep resolved mcp secrets out of header warnings and userinfo urls Co-Authored-By: Claude Opus 5.5 * fix: drop the url from mcp client errors before rmcp logs them Co-Authored-By: Claude Opus 5.5 * fix: run rmcp's reqwest client without a log subscriber Co-Authored-By: Claude Opus 5.5 * fix: scrub the request target from mcp error bodies Co-Authored-By: Claude Opus 5.5 * fix: drop server error bodies from mcp transport errors Co-Authored-By: Claude Opus 5.5 * fix: fail closed when an mcp error message has no status prefix Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/Cargo.lock | 1 + backend/tests/fixtures/mcp_resource_authz.sql | 16 ++ backend/tests/mcp_resource_authz.rs | 43 ++++- backend/windmill-api/src/mcp_tools.rs | 6 +- backend/windmill-mcp/Cargo.toml | 1 + backend/windmill-mcp/src/client/mod.rs | 5 +- backend/windmill-mcp/src/client/redacting.rs | 151 ++++++++++++++++++ backend/windmill-worker/src/ai/utils.rs | 11 ++ 8 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 backend/windmill-mcp/src/client/redacting.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1bd38513a7..86b5850e80 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16523,6 +16523,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "sse-stream", "tokio", "tokio-util", "tracing", diff --git a/backend/tests/fixtures/mcp_resource_authz.sql b/backend/tests/fixtures/mcp_resource_authz.sql index ecf3f01dc3..17fcc553b9 100644 --- a/backend/tests/fixtures/mcp_resource_authz.sql +++ b/backend/tests/fixtures/mcp_resource_authz.sql @@ -23,3 +23,19 @@ VALUES ( '{}', 'test-user' ); + +-- MCP resource whose URL lives in a variable. The host only appears in the +-- variable, so an error naming it proves the reference was resolved. +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'u/test-user/mcp_url', 'https://mcp-from-var.invalid.windmill.test/key', false, '', '{}'); + +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ( + 'test-workspace', + 'u/test-user/var_mcp', + '{"name": "var_mcp", "url": "$var:u/test-user/mcp_url"}', + 'MCP resource with its URL in a variable', + 'mcp', + '{}', + 'test-user' +); diff --git a/backend/tests/mcp_resource_authz.rs b/backend/tests/mcp_resource_authz.rs index 0fa35933f4..6ccf5f4f2d 100644 --- a/backend/tests/mcp_resource_authz.rs +++ b/backend/tests/mcp_resource_authz.rs @@ -23,9 +23,9 @@ use windmill_common::client::AuthedClient; use windmill_test_utils::*; use windmill_worker::{load_mcp_tools, McpResourceConfig}; -fn config() -> Vec { +fn config(resource_path: &str) -> Vec { vec![McpResourceConfig { - resource_path: "$res:f/private/admin_mcp".to_string(), + resource_path: resource_path.to_string(), include_tools: None, exclude_tools: None, }] @@ -34,7 +34,16 @@ fn config() -> Vec { // The Ok variant ((HashMap<_, Arc>, Vec)) does not implement // Debug, so `expect_err` is unavailable — extract the error explicitly. async fn load_err(db: &Pool, client: &AuthedClient, ctx: &str) -> String { - match load_mcp_tools(db, "test-workspace", config(), client).await { + load_err_for(db, client, "$res:f/private/admin_mcp", ctx).await +} + +async fn load_err_for( + db: &Pool, + client: &AuthedClient, + resource_path: &str, + ctx: &str, +) -> String { + match load_mcp_tools(db, "test-workspace", config(resource_path), client).await { Ok(_) => panic!("{ctx}"), Err(e) => e.to_string(), } @@ -100,3 +109,31 @@ async fn test_mcp_resource_not_loaded_without_authorization( Ok(()) } + +/// A `$var:` in the resource's `url` is resolved before connecting, rather than +/// being handed to the client as a literal (and rejected as a relative URL). +#[sqlx::test(fixtures("base", "mcp_resource_authz"))] +async fn test_mcp_resource_url_variable_is_resolved(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let client = AuthedClient::new( + format!("http://localhost:{}", server.addr.port()), + "test-workspace".to_string(), + "SECRET_TOKEN".to_string(), + None, + ); + let msg = load_err_for( + &db, + &client, + "$res:u/test-user/var_mcp", + "the variable's URL is non-resolvable, so the connect step must fail", + ) + .await; + assert!( + msg.contains("mcp-from-var.invalid.windmill.test"), + "the URL should come from the variable: {msg}" + ); + + Ok(()) +} diff --git a/backend/windmill-api/src/mcp_tools.rs b/backend/windmill-api/src/mcp_tools.rs index 1e07f374de..a9049951a3 100644 --- a/backend/windmill-api/src/mcp_tools.rs +++ b/backend/windmill-api/src/mcp_tools.rs @@ -125,8 +125,10 @@ async fn connect_mcp_client( None }; - // Header secrets go through the same permissioned path as the token. - for value in mcp_resource.headers.iter_mut().flatten().map(|(_, v)| v) { + // The URL and header secrets go through the same permissioned path as the token. + for value in std::iter::once(&mut mcp_resource.url) + .chain(mcp_resource.headers.iter_mut().flatten().map(|(_, v)| v)) + { if let Some(var_path) = value.strip_prefix("$var:") { check_scopes(authed, || format!("variables:read:{}", var_path))?; let db_authed = DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone())); diff --git a/backend/windmill-mcp/Cargo.toml b/backend/windmill-mcp/Cargo.toml index 8ac23e885d..2c07b92d44 100644 --- a/backend/windmill-mcp/Cargo.toml +++ b/backend/windmill-mcp/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true serde_json.workspace = true tracing.workspace = true rmcp.workspace = true +sse-stream = "0.2" sqlx = { workspace = true, optional = true } chrono = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index 5d13a40314..6f3928e112 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -3,11 +3,13 @@ //! This module provides functionality for connecting to external MCP servers //! and executing tools on them. +mod redacting; mod types; pub use types::{McpResource, McpToolSource}; use anyhow::{Context, Result}; +use redacting::UrlRedactingClient; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use rmcp::{ model::{ @@ -97,7 +99,8 @@ impl McpClient { // Create the HTTP transport with custom client let config = StreamableHttpClientTransportConfig::with_uri(resource.url.as_str()); - let transport = StreamableHttpClientTransport::with_client(reqwest_client, config); + let transport = + StreamableHttpClientTransport::with_client(UrlRedactingClient(reqwest_client), config); // Set up client info let client_info = ClientInfo::new( diff --git a/backend/windmill-mcp/src/client/redacting.rs b/backend/windmill-mcp/src/client/redacting.rs new file mode 100644 index 0000000000..6abcc50f50 --- /dev/null +++ b/backend/windmill-mcp/src/client/redacting.rs @@ -0,0 +1,151 @@ +use std::{borrow::Cow, collections::HashMap, future::Future, sync::Arc}; + +use futures::stream::BoxStream; +use reqwest::header::{HeaderName, HeaderValue}; +use rmcp::{ + model::ClientJsonRpcMessage, + transport::streamable_http_client::{ + SseError, StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::Sse; +use tracing::{instrument::WithSubscriber, Dispatch}; + +type Error = StreamableHttpError; + +/// reqwest names the URL it failed on in its errors, and an MCP server URL can +/// carry the server's key. rmcp logs those errors as well as returning them, so +/// the URL is dropped before the transport sees one, and rmcp's reqwest client, +/// which logs the errors it swallows, runs without a subscriber. +#[derive(Clone)] +pub(super) struct UrlRedactingClient(pub(super) reqwest::Client); + +async fn redacted(request: impl Future>) -> Result { + match request.with_subscriber(Dispatch::none()).await { + Err(StreamableHttpError::Client(e)) => Err(StreamableHttpError::Client(e.without_url())), + // An owned message is `HTTP : `, and the body is the server's + // to write: a common one echoes the request target (`Cannot POST /mcp/`). + Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned(message))) => { + let status = message.split_once(": ").map_or("", |(status, _)| status); + let status = status.to_string(); + Err(StreamableHttpError::UnexpectedServerResponse(status.into())) + } + r => r, + } +} + +impl StreamableHttpClient for UrlRedactingClient { + type Error = reqwest::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + ) -> Result { + redacted( + self.0 + .post_message(uri, message, session_id, auth_header, custom_headers), + ) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result { + redacted(self.0.post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_header, + custom_headers, + max_sse_event_size, + )) + .await + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> Result<(), Error> { + redacted( + self.0 + .delete_session(uri, session_id, auth_header, custom_headers), + ) + .await + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + ) -> Result>, Error> { + redacted( + self.0 + .get_stream(uri, session_id, last_event_id, auth_header, custom_headers), + ) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result>, Error> { + redacted(self.0.get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_header, + custom_headers, + max_sse_event_size, + )) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn server_error_body_is_dropped() { + for (message, expected) in [ + ( + "HTTP 404 Not Found: Cannot POST /mcp/KEY", + "HTTP 404 Not Found", + ), + ("Cannot POST /mcp/KEY", ""), + ] { + let err = redacted(async { + Err::<(), Error>(StreamableHttpError::UnexpectedServerResponse( + message.to_string().into(), + )) + }) + .await + .unwrap_err(); + let StreamableHttpError::UnexpectedServerResponse(got) = err else { + panic!("unexpected variant: {err}"); + }; + assert_eq!(got, expected); + } + } +} diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index f0a0b5c20f..44513c806e 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -615,6 +615,17 @@ pub async fn load_mcp_tools( None }; + // A URL can carry the server's key, so it goes through the same + // permissioned path as the token. + if let Some(var_path) = mcp_resource.url.strip_prefix("$var:") { + mcp_resource.url = client.get_variable_value(var_path).await.map_err(|e| { + Error::internal_err(format!( + "Failed to resolve variable {} for the url of MCP resource {}: {}", + var_path, resource_name, e + )) + })?; + } + // Header secrets go through the same permissioned path as the token. for (name, value) in mcp_resource.headers.iter_mut().flatten() { if let Some(var_path) = value.strip_prefix("$var:") { From 71caac7aec49bafe8f691e8a82b3e6df84f7e4bc Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:53:51 +0200 Subject: [PATCH 16/28] fix: keep the resource type index whole when a refresh fails (#11727) Claude-Session: https://claude.ai/code/session_015YR13nJWziUSWktZwhC7dx Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- backend/windmill-api-embeddings/src/lib.rs | 73 +++++++++++++++------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 70528491e8..a470648cbb 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -160,29 +160,47 @@ async fn match_resource_types_by_text( .filter(|word| !word.is_empty()) .map(str::to_string) .collect(); - let rows: Vec<(String, Option, Option)> = sqlx::query_as( - "SELECT name, description, schema FROM resource_type WHERE workspace_id = $1 OR workspace_id = 'admins'", + let rows: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT workspace_id, name, description FROM resource_type WHERE workspace_id = $1 OR workspace_id = 'admins'", ) .bind(w_id) .fetch_all(db) .await?; - let mut results: Vec = rows + let mut ranked: Vec<(f32, String, String)> = rows .into_iter() - .map(|(name, description, schema)| ResourceTypeResult { - score: text_match_score(&words, &name, description.as_deref().unwrap_or_default()), - name, - schema, + .map(|(workspace_id, name, description)| { + let score = text_match_score(&words, &name, description.as_deref().unwrap_or_default()); + (score, workspace_id, name) }) - .filter(|rt| rt.score > 0.0) + .filter(|(score, _, _)| *score > 0.0) .collect(); - results.sort_by(|a, b| { - b.score - .total_cmp(&a.score) - .then_with(|| a.name.cmp(&b.name)) - }); - results.truncate(limit.unwrap_or(10).max(0) as usize); - Ok(results) + ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.2.cmp(&b.2))); + ranked.truncate(limit.unwrap_or(10).max(0) as usize); + + // Schemas are read for the kept rows only: a synced instance holds megabytes of them. + let (workspaces, names): (Vec<&str>, Vec<&str>) = ranked + .iter() + .map(|(_, workspace_id, name)| (workspace_id.as_str(), name.as_str())) + .unzip(); + let mut schemas: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT workspace_id, name, schema FROM resource_type WHERE (workspace_id, name) IN (SELECT * FROM unnest($1::text[], $2::text[]))", + ) + .bind(&workspaces) + .bind(&names) + .fetch_all(db) + .await?; + + Ok(ranked + .into_iter() + .map(|(score, workspace_id, name)| { + let schema = schemas + .iter_mut() + .find(|(w, n, _)| *w == workspace_id && *n == name) + .and_then(|(_, _, schema)| schema.take()); + ResourceTypeResult { score, name, schema } + }) + .collect()) } /// A word found in the name counts twice a word found in the description: names are what @@ -533,14 +551,19 @@ impl EmbeddingsDb { } fn replace_resource_types(&mut self, index: ResourceTypeIndex) -> Result<()> { - if self.db.get_collection("resource_types").is_some() { - self.db.delete_collection("resource_types")?; - } + // Built aside and swapped in whole: an insert the collection refuses must not leave + // searches on a half-filled index. + const NEXT: &str = "resource_types_next"; + self.db.collections.remove(NEXT); self.db - .create_collection("resource_types".to_string(), 384, Distance::Cosine)?; + .create_collection(NEXT.to_string(), 384, Distance::Cosine)?; for embedding in index.embeddings { + self.db.insert_into_collection(NEXT, embedding)?; + } + if let Some(next) = self.db.collections.remove(NEXT) { self.db - .insert_into_collection("resource_types", embedding)?; + .collections + .insert("resource_types".to_string(), next); } self.resource_types_fingerprint = index.fingerprint; self.custom_resource_type_vectors = index.custom_vectors; @@ -754,7 +777,9 @@ async fn resource_type_index( }; embeddings.push(Embedding { - id: format!("{}_{}", rt.workspace_id, rt.name), + // Neither part can hold a `/`, so two types never share an id; with `_` between + // them, `acme_c` + `c_x` and `acme` + `c_c_x` would. + id: format!("{}/{}", rt.workspace_id, rt.name), vector, metadata: Some(hm), }); @@ -782,7 +807,11 @@ pub async fn refresh_resource_type_embeddings(pg_db: &Pool) -> Result< let index = resource_type_index(pg_db, &model_instance, &hub_embeddings, &custom_vectors).await?; if let Some(db) = EMBEDDINGS_DB.write().await.as_mut() { - db.replace_resource_types(index)?; + // A full fill that landed meanwhile indexed the types from newer hub vectors than + // the ones this index was built from. + if Arc::ptr_eq(&db.hub_resource_type_embeddings, &hub_embeddings) { + db.replace_resource_types(index)?; + } } Ok(()) } From 07bf7e6c1e82d9df82d31025b39eb68ceb911120 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 16:01:19 +0200 Subject: [PATCH 17/28] chore(main): release 1.831.0 (#11664) * chore(main): release 1.831.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 47 +++ backend/Cargo.lock | 346 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +-- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- chat-sdk/package-lock.json | 4 +- chat-sdk/package.json | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 22 files changed, 271 insertions(+), 216 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fdce702331..0b517c26f7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.830.0" + ".": "1.831.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 80939b1b47..98686e7917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## [1.831.0](https://github.com/windmill-labs/windmill/compare/v1.830.0...v1.831.0) (2026-10-13) + + +### Features + +* add python 3.14 and 3.15 to the supported instance versions ([#11654](https://github.com/windmill-labs/windmill/issues/11654)) ([74c9b0c](https://github.com/windmill-labs/windmill/commit/74c9b0cb2eebc16dd5e23f23be89b127bcbe6ec2)) +* add the viewer's roles to unnamed data table connections ([#11709](https://github.com/windmill-labs/windmill/issues/11709)) ([a298ed3](https://github.com/windmill-labs/windmill/commit/a298ed3fc19a1b7fc4f8e75a95c8d38652b74b5b)) +* audit declarative instance config writes ([#11684](https://github.com/windmill-labs/windmill/issues/11684)) ([afbbc0b](https://github.com/windmill-labs/windmill/commit/afbbc0b1b2da1e468b10d4cd11e9f3ac7478a34d)) +* **cli:** declare app sandbox and frontend api scopes in app yaml ([#11703](https://github.com/windmill-labs/windmill/issues/11703)) ([26afeec](https://github.com/windmill-labs/windmill/commit/26afeecbd5b0a11c084813182e92805a15d21bbb)) +* data table migration tools in ai sessions ([#11723](https://github.com/windmill-labs/windmill/issues/11723)) ([3d74f6e](https://github.com/windmill-labs/windmill/commit/3d74f6ea2a44e85d056411c293087741f6f1e12a)) +* encrypt workspace secrets with AES-256-GCM ([#11678](https://github.com/windmill-labs/windmill/issues/11678)) ([4942149](https://github.com/windmill-labs/windmill/commit/4942149b05c9384b39402f0e3cb7771565888f45)) +* external signer for OIDC tokens and a log line per mint ([#11614](https://github.com/windmill-labs/windmill/issues/11614)) ([157183a](https://github.com/windmill-labs/windmill/commit/157183ae9cad4b6b2d38f1f4d915f32464792453)) +* give app builder operators app tools in AI sessions ([#11716](https://github.com/windmill-labs/windmill/issues/11716)) ([133613c](https://github.com/windmill-labs/windmill/commit/133613c1add5b6fcdbb0937de6479976b716c90a)) +* redesign the s3 explorer and show data files as tables ([#11642](https://github.com/windmill-labs/windmill/issues/11642)) ([9fa9b77](https://github.com/windmill-labs/windmill/commit/9fa9b7792676fbedcbc4dc37ebb883f5ff5abb35)) +* support array arguments in snowflake scripts ([#11702](https://github.com/windmill-labs/windmill/issues/11702)) ([1451988](https://github.com/windmill-labs/windmill/commit/14519884e4c9aa4d0a006cbc019a776f9a038967)) + + +### Bug Fixes + +* bump git sync hub scripts to cli 1.830.0 ([#11681](https://github.com/windmill-labs/windmill/issues/11681)) ([7bbe032](https://github.com/windmill-labs/windmill/commit/7bbe03227d56688893aafda305a13d65b2bb84f4)) +* end other sessions when a user changes their password ([#11710](https://github.com/windmill-labs/windmill/issues/11710)) ([add042e](https://github.com/windmill-labs/windmill/commit/add042ef2f05828a8ea5f6d0a089204bae2322d5)) +* fill app ctx inputs from the viewer's session, not the request ([#11698](https://github.com/windmill-labs/windmill/issues/11698)) ([7627c1b](https://github.com/windmill-labs/windmill/commit/7627c1be8d82c298173ad4a7002483d1caee6662)) +* **groups:** recompute member roles when an instance group role is cleared ([#11708](https://github.com/windmill-labs/windmill/issues/11708)) ([4be67fe](https://github.com/windmill-labs/windmill/commit/4be67fe3ac09f722f31ef85e6fc757bb0a2fabe4)) +* hide the sidebar triggers header when no trigger entry is visible ([#11715](https://github.com/windmill-labs/windmill/issues/11715)) ([e443eb3](https://github.com/windmill-labs/windmill/commit/e443eb37079734469a78af8ff4bc0f4289b28d0a)) +* keep a custom error handler's slack argument on save ([#11696](https://github.com/windmill-labs/windmill/issues/11696)) ([c6692ac](https://github.com/windmill-labs/windmill/commit/c6692ace156f5295196af07015ad82aee6391116)) +* keep ai agent schema property order stable across jobs ([#11687](https://github.com/windmill-labs/windmill/issues/11687)) ([a8e2f1f](https://github.com/windmill-labs/windmill/commit/a8e2f1f1ca28e3352aff8a450188e3ff75a9637c)) +* keep preserve_step_tags on saved branch and loop flow nodes ([#11660](https://github.com/windmill-labs/windmill/issues/11660)) ([dedd775](https://github.com/windmill-labs/windmill/commit/dedd77587f3234f3e480388b00340394794ac5bb)) +* keep the app editor's parent_version out of deployed apps ([#11719](https://github.com/windmill-labs/windmill/issues/11719)) ([2c6de9d](https://github.com/windmill-labs/windmill/commit/2c6de9d896ef37993da974418133a88798afa2bb)) +* keep the resource type index whole when a refresh fails ([#11727](https://github.com/windmill-labs/windmill/issues/11727)) ([71caac7](https://github.com/windmill-labs/windmill/commit/71caac7aec49bafe8f691e8a82b3e6df84f7e4bc)) +* keep the resource type search current between daily fills, and serve it without an index ([#11712](https://github.com/windmill-labs/windmill/issues/11712)) ([a365359](https://github.com/windmill-labs/windmill/commit/a3653593f2b064b762a19a1d4294988c27baaec0)) +* load the s3 explorer table lazily to keep monaco out of the app shell ([#11701](https://github.com/windmill-labs/windmill/issues/11701)) ([a92b29d](https://github.com/windmill-labs/windmill/commit/a92b29dae6950d29c2b8970e07d055fa02ff979e)) +* parse python 3.12-3.15 syntax in the python parsers ([#11675](https://github.com/windmill-labs/windmill/issues/11675)) ([aae5f2e](https://github.com/windmill-labs/windmill/commit/aae5f2efaa772d5bc6d7d1e4537d7f6748e50e61)) +* persist hidden languages in edit default scripts drawer ([#11693](https://github.com/windmill-labs/windmill/issues/11693)) ([aeeeb04](https://github.com/windmill-labs/windmill/commit/aeeeb042f93df32214e00040d360f20e8ed8c98a)) +* pin the floating sidebar and let page clicks through it ([#11711](https://github.com/windmill-labs/windmill/issues/11711)) ([ce45ab5](https://github.com/windmill-labs/windmill/commit/ce45ab507370662266728bf57240cc873ef28eb8)) +* resolve $var: references in mcp resource headers ([#11679](https://github.com/windmill-labs/windmill/issues/11679)) ([478eef7](https://github.com/windmill-labs/windmill/commit/478eef735222e60de8ed6086f6b036e2346f1de4)) +* resolve a new item's chosen name in session preview tools ([#11688](https://github.com/windmill-labs/windmill/issues/11688)) ([cd502a5](https://github.com/windmill-labs/windmill/commit/cd502a5270e787aeb1e5cb14e5a0e0aad8bb97b3)) +* resolve variables in the url and headers of an mcp resource ([#11680](https://github.com/windmill-labs/windmill/issues/11680)) ([4bc2394](https://github.com/windmill-labs/windmill/commit/4bc2394322f957e5cf212fa1913b2b39c1b9ca06)) +* run a workflow-as-code task called from inside a task in place ([#11699](https://github.com/windmill-labs/windmill/issues/11699)) ([72d1092](https://github.com/windmill-labs/windmill/commit/72d1092de97d7449ff03c3a44835c9d23cfe5e02)) +* stop marking run form fields as disabled for assistive tech ([#11714](https://github.com/windmill-labs/windmill/issues/11714)) ([c7df009](https://github.com/windmill-labs/windmill/commit/c7df0093d0d9a38362222b7350bf387c31af9392)) +* stop session dom tools reading a raw app preview that is not current ([#11700](https://github.com/windmill-labs/windmill/issues/11700)) ([05c7b68](https://github.com/windmill-labs/windmill/commit/05c7b688e5e963c6c313cb5da8e777cee11cd122)) +* surface oauth provider errors returned with http 200 ([#11713](https://github.com/windmill-labs/windmill/issues/11713)) ([d8ac4f5](https://github.com/windmill-labs/windmill/commit/d8ac4f59c8d570777e43a7aa7ffbc97598897cc2)) + + +### Performance Improvements + +* run shellcheck directly from the lsp launcher ([#11707](https://github.com/windmill-labs/windmill/issues/11707)) ([8cdb42a](https://github.com/windmill-labs/windmill/commit/8cdb42aaae764977008f8ac4598709c7ae09af3f)) + ## [1.830.0](https://github.com/windmill-labs/windmill/compare/v1.829.0...v1.830.0) (2026-10-09) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 86b5850e80..9ee5635c37 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -864,7 +864,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -903,7 +903,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -960,7 +960,7 @@ dependencies = [ "manyhow 0.14.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -976,7 +976,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1029,11 +1029,11 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ - "aws-lc-sys 0.45.0", + "aws-lc-sys 0.46.0", "untrusted 0.7.1", "zeroize", ] @@ -1052,9 +1052,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -1336,9 +1336,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.12" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -1421,7 +1421,7 @@ dependencies = [ "pin-project-lite", "rustls 0.21.12", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -2010,7 +2010,7 @@ dependencies = [ "prettyplease 0.3.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2034,7 +2034,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2145,7 +2145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c719d56f7e96194cfc53460976d3ba51c85719747c9c62ed99981847b551152b" dependencies = [ "rust_decimal", - "schemars 1.2.2", + "schemars 1.2.3", "serde", "utf8-width", ] @@ -2189,7 +2189,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2366,9 +2366,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -2521,7 +2521,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2793,6 +2793,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -3214,7 +3220,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3269,7 +3275,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4575,9 +4581,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] @@ -4766,7 +4772,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4786,7 +4792,7 @@ dependencies = [ "serde", "smallvec", "strck", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4880,7 +4886,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5181,7 +5187,7 @@ checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5679,7 +5685,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5889,7 +5895,7 @@ checksum = "28f0daa179069133df60523e0f59d0a6bd2d3db9ccae01b630896d269c4a802e" dependencies = [ "attribute-derive", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6735,7 +6741,7 @@ dependencies = [ "hyper-util", "log", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "tokio", "tokio-rustls 0.26.6", "tower-service", @@ -7801,9 +7807,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.29" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" dependencies = [ "cc", "libc", @@ -8037,7 +8043,7 @@ dependencies = [ "manyhow-macros 0.14.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -9972,7 +9978,7 @@ dependencies = [ "md-5 0.11.0", "memchr", "rand 0.10.3", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", ] @@ -10041,7 +10047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10160,14 +10166,15 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha 0.9.0", + "rand 0.10.3", "rand_xorshift", "regex-syntax 0.8.11", "unarray", @@ -10417,7 +10424,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10576,11 +10583,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -10773,7 +10780,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10863,7 +10870,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "serde", "serde_json", @@ -11078,7 +11085,7 @@ dependencies = [ "rand 0.10.3", "reqwest 0.13.5", "rmcp-macros", - "schemars 1.2.2", + "schemars 1.2.3", "serde", "serde_json", "sse-stream", @@ -11102,7 +11109,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11498,9 +11505,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -11548,7 +11555,7 @@ dependencies = [ "log", "once_cell", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-platform-verifier-android", "rustls-webpki 0.103.15", "security-framework 3.7.0", @@ -11735,14 +11742,14 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive 1.2.2", + "schemars_derive 1.2.3", "serde", "serde_json", ] @@ -11761,14 +11768,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", "serde_derive_internals 0.30.0", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11966,7 +11973,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11988,15 +11995,16 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", "indexmap 2.14.2", "itoa", "memchr", @@ -12042,7 +12050,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -12094,7 +12102,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -12110,7 +12118,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -12223,9 +12231,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -12414,9 +12422,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -13345,9 +13353,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -13405,7 +13413,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -13809,7 +13817,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14070,7 +14078,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14261,7 +14269,7 @@ dependencies = [ "httparse", "rand 0.8.5", "ring 0.17.14", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -14383,7 +14391,7 @@ dependencies = [ "percent-encoding", "pin-project", "prost 0.13.5", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "socket2 0.5.10", "tokio", "tokio-rustls 0.26.6", @@ -14664,9 +14672,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +checksum = "0d15f4f5898bcfc5d8a8e45ed30b2f108d710c1a2e5489dbf80e078f707181bc" dependencies = [ "serde", "stable_deref_trait", @@ -14774,7 +14782,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14862,9 +14870,9 @@ checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -15043,9 +15051,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -15224,7 +15232,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -15267,7 +15275,7 @@ checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -15478,7 +15486,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -15568,7 +15576,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.830.0" +version = "1.831.0" dependencies = [ "async-stream", "async-trait", @@ -15603,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15616,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "argon2", @@ -15759,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15782,7 +15790,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15799,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -15825,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.830.0" +version = "1.831.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15835,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15852,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "base64 0.22.1", @@ -15874,7 +15882,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -15897,7 +15905,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15913,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15935,7 +15943,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15957,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15971,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -16007,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16033,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16061,7 +16069,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16083,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16103,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16145,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16174,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.830.0" +version = "1.831.0" dependencies = [ "lazy_static", "serde", @@ -16186,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.830.0" +version = "1.831.0" dependencies = [ "argon2", "axum", @@ -16210,7 +16218,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16224,7 +16232,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16263,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.830.0" +version = "1.831.0" dependencies = [ "chrono", "lazy_static", @@ -16278,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16297,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.830.0" +version = "1.831.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16368,7 +16376,7 @@ dependencies = [ "reqwest-retry", "rsa", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "ryu-js", "schemars 0.8.22", "semver", @@ -16413,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.830.0" +version = "1.831.0" dependencies = [ "chrono", "futures", @@ -16433,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.830.0" +version = "1.831.0" dependencies = [ "regex", "serde", @@ -16450,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16477,7 +16485,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "futures", @@ -16494,7 +16502,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.830.0" +version = "1.831.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16510,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -16532,7 +16540,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -16563,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "arc-swap", @@ -16588,7 +16596,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-stream", @@ -16624,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "futures", @@ -16643,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.830.0" +version = "1.831.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16652,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16664,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16676,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "gosyn", @@ -16688,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16700,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16712,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "nu-parser", @@ -16723,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16734,7 +16742,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16746,7 +16754,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16757,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -16780,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16792,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16806,7 +16814,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16823,7 +16831,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16836,7 +16844,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde", @@ -16848,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16866,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16882,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16899,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16913,7 +16921,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -16952,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "const_format", @@ -16990,7 +16998,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.830.0" +version = "1.831.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17001,7 +17009,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -17036,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17060,7 +17068,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17093,7 +17101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17120,7 +17128,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17153,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17173,7 +17181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17207,7 +17215,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17244,7 +17252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17267,7 +17275,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17291,7 +17299,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -17315,7 +17323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17350,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17378,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17403,7 +17411,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17422,7 +17430,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-once-cell", @@ -17484,7 +17492,7 @@ dependencies = [ "rsa", "rust_decimal", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pemfile 2.2.0", "serde", "serde_json", @@ -17541,7 +17549,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.830.0" +version = "1.831.0" dependencies = [ "bytes", "futures", @@ -18084,9 +18092,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "version_check", ] @@ -18261,7 +18269,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -18302,7 +18310,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -18358,7 +18366,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index aaf5a2e8df..a72480cab2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.830.0" +version = "1.831.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.830.0" +version = "1.831.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 51878c6dfb..c0d6aa95d4 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.830.0" +version = "1.831.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.830.0" +version = "1.831.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.830.0" +version = "1.831.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 8aebf20597..d72119afd4 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.830.0" +version = "1.831.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 53996db1dc..5295c5765f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.830.0 + version: 1.831.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 34e4da5cf3..63e9a8a5d4 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.830.0"; +export const VERSION = "v1.831.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 74f6914f37..91e9b9a081 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.830.0", + "version": "1.831.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.830.0", + "version": "1.831.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 26f5f785be..296be0e680 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.830.0", + "version": "1.831.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 21aeb07f60..b6c029daa2 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.830.0"; +export const VERSION = "1.831.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6130364ff9..3b5ecd97be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 7522af8675..33c24f43bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 4aee9bdf9f..4b782c4cf0 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.830.0" +wmill = ">=1.831.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index e3759ee6a6..19ba842fff 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.830.0 + version: 1.831.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 8385126639..573be0aa76 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.830.0' + ModuleVersion = '1.831.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 411bc09cc8..dc227b7ccc 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.830.0" +version = "1.831.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index ffd7c2300a..3eaaece304 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.830.0", + "version": "1.831.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 9adbfcec62..810c0a7940 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.830.0", + "version": "1.831.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 54f09db472..74dfad0170 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.830.0 +1.831.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 95c5128a13..94b3c36b9a 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.830.0", + "version": "1.831.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.830.0", + "version": "1.831.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 6f2c2d7fed..77dbdfcecf 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.830.0", + "version": "1.831.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 9c44f5a917fc628e870073c126bf9480c98c7f73 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 16:20:53 +0200 Subject: [PATCH 18/28] fix: badge the unavailable sign-in option in the oauth resource form (#11728) Co-authored-by: Claude Opus 5.5 --- frontend/src/lib/components/AppConnectInner.svelte | 6 +++++- .../src/lib/components/common/radioCard/RadioCard.svelte | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 18a28ec95d..4dc05972ca 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -25,7 +25,7 @@ import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry' import { createEventDispatcher, onDestroy, tick } from 'svelte' import Path from './Path.svelte' - import { ListRow, RadioCard, Skeleton } from './common' + import { Badge, ListRow, RadioCard, Skeleton } from './common' import { useListHighlight } from './common/listRow/listHighlight.svelte' import ApiConnectForm from './ApiConnectForm.svelte' import SearchItems from './SearchItems.svelte' @@ -1545,7 +1545,11 @@ selected={!useClientCredentials} onSelect={selectAuthCodeGrant} disabled={signInNeedsInstanceClient} + badge={signInNeedsInstanceClient ? setupNeededBadge : undefined} /> + {#snippet setupNeededBadge()} + Instance setup needed + {/snippet} {@render icon()}
{/if}
-
{label}
+
+ {label} + {@render badge?.()} +
{#if describedBySnippet}
{@render (description as Snippet)()} From e76ebcb2db0668ff39b546a3bcfe188e974d73e3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 16:40:55 +0200 Subject: [PATCH 19/28] chore: first-time setup follow-up (#11730) * chore: update ee ref Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 118785eaa78675089b9a676d24e5d0a2e03f3d9a This commit updates the EE repository reference after PR #875 was merged in windmill-ee-private. Previous ee-repo-ref: e8df378c5f8e5e07af8a2bad4c685c8ad6fe1491 New ee-repo-ref: 118785eaa78675089b9a676d24e5d0a2e03f3d9a Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index d68805fb8a..408bf517b1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0a7c28718872bb5cd624d77e32ad7d75c6ce42de +118785eaa78675089b9a676d24e5d0a2e03f3d9a From 23f6e69bf29249e0b2ca4d3ef43f457918b54844 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 13 Oct 2026 07:58:42 -0700 Subject: [PATCH 20/28] feat: shrink the page header in stages and let the bar fold page actions Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/lib/components/FlowBuilder.svelte | 22 +- .../src/lib/components/NavBreadcrumb.svelte | 357 ++++++++++++------ .../src/lib/components/PageHeaderBar.svelte | 185 ++++++--- .../lib/components/PageHeaderContent.svelte | 2 + .../src/lib/components/ScriptBuilder.svelte | 22 +- .../apps/editor/AppEditorHeader.svelte | 29 +- .../details/DetailPageHeader.svelte | 12 +- .../components/pageHeaderRegistry.svelte.ts | 27 +- .../lib/components/pageHeaderStages.test.ts | 51 +++ .../src/lib/components/pageHeaderStages.ts | 186 +++++++++ .../raw_apps/RawAppEditorHeader.svelte | 28 +- 11 files changed, 690 insertions(+), 231 deletions(-) create mode 100644 frontend/src/lib/components/pageHeaderStages.test.ts create mode 100644 frontend/src/lib/components/pageHeaderStages.ts diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 1a468ac33a..0e2348336c 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -40,7 +40,7 @@ import { getContext, onDestroy, setContext, untrack, type Snippet } from 'svelte' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import PathEditPopover from '$lib/components/PathEditPopover.svelte' - import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { writable } from 'svelte/store' import CenteredPage from './CenteredPage.svelte' import { Button } from './common' @@ -230,19 +230,14 @@ let pathSnapshot = $state(undefined) let topbarWidth = $state(0) - // In the page header the buttons share the row with the breadcrumb, so they collapse sooner: - // the trail and the summary take ~560px before either truncates, and the full group — the - // dropdown, Diff, the preview trio and Deploy — is ~610px. - const compactBelow = $derived(ownsPageHeader ? 1200 : 720) - const compactTopbar = $derived.by(() => { - const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth - return w > 0 && w < compactBelow - }) - // A phone's bar holds the trail and Deploy: the diff joins the menu and the preview buttons - // stand down, since there is no room to run a flow beside its own name. - const phoneTopbar = $derived( - ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR + // In the page header the bar folds the buttons (`actionFolds` below), once its breadcrumb has + // given all it can; anywhere else this editor's own top bar is theirs alone. + const compactTopbar = $derived( + ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720 ) + // The second fold holds the trail and Deploy: the diff joins the menu and the preview buttons + // stand down, since there is no room to run a flow beside its own name. + const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2) const diffEnabled = $derived(customUi?.topBar?.diff != false) // Nothing to compare against until a deployed version exists. @@ -1597,6 +1592,7 @@ : undefined }} actions={flowHeaderActions} + actionFolds={2} contexts={headerContexts} separator="none" /> diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index b053591fc0..9ae1dc399a 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -16,7 +16,6 @@ not this component) — so the lead can run the whole line. import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte' import { Menu, Menubar } from '$lib/components/meltComponents' import MeltButton from '$lib/components/meltComponents/MeltButton.svelte' - import BreadcrumbItemContent from './BreadcrumbItemContent.svelte' import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte' import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte' import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores' @@ -33,14 +32,25 @@ not this component) — so the lead can run the whole line. import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker' import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte' import type { Snippet } from 'svelte' + import { + BAR_STAGES, + FLOOR, + shownPathLevels, + type Part, + type TrailMeasures, + type TrailStage + } from './pageHeaderStages' let { item, section, afterName, actingWorkspaceId, - narrow = false, - nameOnly = false + nameOnly = false, + stage = BAR_STAGES[0], + caps = {}, + scopesGive = false, + measures = $bindable() }: { item?: PageHeaderItem section?: PageHeaderSection @@ -49,9 +59,14 @@ not this component) — so the lead can run the whole line. afterName?: Snippet /** The workspace the page acts on, when it differs from the one the app is pointed at. */ actingWorkspaceId?: string - /** The bar is short of room: the path holds a tighter cap, so it cannot claim a third of the - * line before anything else has given way. The names truncate at every width either way. */ - narrow?: boolean + /** Which parts the bar has dropped for room (see pageHeaderStages). */ + stage?: TrailStage + /** Max widths the bar holds the names to, once dropping whole parts is not enough. */ + caps?: Partial> + /** The folders above the name truncate too: even the name at its last floor does not fit. */ + scopesGive?: boolean + /** Filled in for the bar to choose a stage with; left undefined, nothing is measured. */ + measures?: TrailMeasures /** Only the page's own name, with no workspace part and no picker — the band inside a * session's preview frame, where the workspace is the host's and leading the reader out of * it is exactly what that band must not offer. The page still has to say what it is. */ @@ -156,15 +171,8 @@ not this component) — so the lead can run the whole line. twMerge(SEGMENT, 'hover:bg-transparent', workspaceIsLast && !showFork ? LAST : LEAD) ) - // Capped: a draft's path is a 40-character uuid slug, and the trail yields so grudgingly that - // one would push a page's buttons off the end of the bar. const pathSegment = $derived( - twMerge( - SEGMENT, - 'gap-1.5 overflow-hidden', - itemIsLast ? LAST : LEAD, - narrow ? 'max-w-[11rem]' : 'max-w-[16rem]' - ) + twMerge(SEGMENT, 'gap-1.5 overflow-hidden', itemIsLast ? LAST : LEAD) ) // Copying is the one thing a segment does that leaves no mark on the page, so it is the one // without a hover fill: the pointer and the confirmation under it are the affordance. A segment @@ -191,6 +199,18 @@ not this component) — so the lead can run the whole line. const scopeKind = $derived(item?.path?.split('/')[0]) /** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */ const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean)) + + // The off-screen copies stand in a fixed-width box for what a page renders into the trail: a + // second render of a page's snippet would be a second live copy of its controls. + let afterNameWidth = $state(0) + let sectionContentWidth = $state(0) + + /** A measured copy holds each name at its floor; the trail on screen at the bar's cap. */ + function capStyle(part: Part, measure: boolean): string | undefined { + if (measure) return `max-width: ${FLOOR[part]}px` + const cap = caps[part] + return cap === undefined ? undefined : `max-width: ${cap}px` + } {#snippet workspaceDisc()} @@ -241,80 +261,114 @@ not this component) — so the lead can run the whole line. {/snippet} -