From ae093b7666cefc3777be0e45144bb272732846d0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 15:56:11 +0200 Subject: [PATCH 001/174] fix: read inet and cidr results with their prefix, including arrays (#11476) Co-authored-by: Claude Opus 5.5 (1M context) --- backend/Cargo.lock | 1 + backend/windmill-worker/Cargo.toml | 1 + backend/windmill-worker/src/pg_executor.rs | 44 ++++++++++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index abc735f0c7..271ec93546 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16773,6 +16773,7 @@ dependencies = [ "pem", "pep440_rs", "postgres-native-tls 0.5.3", + "postgres-protocol", "process-wrap", "prometheus", "prost", diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 5e95a1e20a..57b292f8f2 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -101,6 +101,7 @@ mappable-rc.workspace = true git-version.workspace = true once_cell.workspace = true tokio-postgres.workspace = true +postgres-protocol.workspace = true bit-vec.workspace = true url.workspace = true async-stream.workspace = true diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 69aa3ee0fb..22a59f82e6 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -1,5 +1,4 @@ use std::collections::{HashMap, HashSet}; -use std::net::IpAddr; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -1889,8 +1888,8 @@ pub fn pg_cell_to_json_value_with_state( Type::UUID => get_basic(row, column, column_i, |a: uuid::Uuid| { Ok(JSONValue::String(a.to_string())) })?, - Type::INET => get_basic(row, column, column_i, |a: IpAddr| { - Ok(JSONValue::String(a.to_string())) + Type::INET | Type::CIDR => get_basic(row, column, column_i, |a: InetStr| { + Ok(JSONValue::String(a.0)) })?, Type::INTERVAL => get_basic(row, column, column_i, |a: IntervalStr| { Ok(JSONValue::String(a.0)) @@ -1997,6 +1996,9 @@ pub fn pg_cell_to_json_value_with_state( Type::BYTEA_ARRAY => get_array(row, column, column_i, |a: Vec| { Ok(JSONValue::String(format!("\\x{}", hex::encode(a)))) })?, + Type::INET_ARRAY | Type::CIDR_ARRAY => get_array(row, column, column_i, |a: InetStr| { + Ok(JSONValue::String(a.0)) + })?, Type::VOID => JSONValue::Null, // Default fallback for unhandled column types: read as text. We use // `AnyTextValue` instead of plain `String` so that `Kind::Enum`, @@ -2156,6 +2158,29 @@ fn get_array<'a, T: FromSql<'a>>( }) } +/// `inet` / `cidr` in Postgres's own text form. `IpAddr`'s `FromSql` drops the +/// prefix length, so a network such as `10.0.0.0/8` would read back as a host. +struct InetStr(String); +impl FromSql<'_> for InetStr { + fn from_sql( + ty: &Type, + raw: &[u8], + ) -> Result> { + let inet = postgres_protocol::types::inet_from_sql(raw)?; + let host_prefix = if inet.addr().is_ipv4() { 32 } else { 128 }; + Ok(InetStr( + if *ty == Type::CIDR || inet.netmask() != host_prefix { + format!("{}/{}", inet.addr(), inet.netmask()) + } else { + inet.addr().to_string() + }, + )) + } + fn accepts(ty: &Type) -> bool { + matches!(*ty, Type::INET | Type::CIDR) + } +} + // you can remove this section if not using TS_VECTOR (or other types requiring an intermediary `FromSQL` struct) struct StringCollector(String); impl FromSql<'_> for StringCollector { @@ -2271,6 +2296,19 @@ mod tests { ); } + #[test] + fn inet_keeps_the_prefix_postgres_prints() { + let inet = |netmask: u8, addr: [u8; 4]| { + let mut raw = vec![2, netmask, 0, 4]; + raw.extend(addr); + raw + }; + let read = |ty: &Type, raw: &[u8]| InetStr::from_sql(ty, raw).unwrap().0; + assert_eq!(read(&Type::INET, &inet(32, [10, 1, 2, 3])), "10.1.2.3"); + assert_eq!(read(&Type::INET, &inet(8, [10, 0, 0, 0])), "10.0.0.0/8"); + assert_eq!(read(&Type::CIDR, &inet(32, [10, 1, 2, 3])), "10.1.2.3/32"); + } + #[test] fn test_parse_naive_date() { // chrono's NaiveDate::to_string() format From 14801fdd68407add824bfa2c679c0e8cb4788610 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 17:21:20 +0200 Subject: [PATCH 002/174] fix: unstick postgres jobs on large results with custom-typed columns (#11475) * fix: unstick postgres jobs on large results with custom-typed columns Co-Authored-By: Claude Opus 5.5 (1M context) * fix: carry the parked-rows delivery fix in the postgres fork Co-Authored-By: Claude Opus 5.5 (1M context) * fix: describe postgres queries before streaming instead of parking rows Co-Authored-By: Claude Opus 5.5 (1M context) * perf: skip the postgres describe for statements that return no rows Co-Authored-By: Claude Opus 5.5 (1M context) * fix: look for returning in the whole postgres statement Co-Authored-By: Claude Opus 5.5 (1M context) * fix: read the leading postgres keyword past nested block comments Co-Authored-By: Claude Opus 5.5 (1M context) * fix: end leading postgres line comments at CR too Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/Cargo.lock | 105 ++++++++++++------ backend/Cargo.toml | 47 +++----- backend/windmill-worker/src/pg_executor.rs | 122 ++++++++++++++++++++- 3 files changed, 206 insertions(+), 68 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 271ec93546..7f660b6b51 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1254,7 +1254,7 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", "http 1.5.0", "percent-encoding", @@ -2489,7 +2489,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "spki", "x509-cert", @@ -2568,6 +2568,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -3894,7 +3900,7 @@ dependencies = [ "aws-lc-rs", "base64 0.22.1", "cbc", - "const-oid", + "const-oid 0.9.6", "ctr", "curve25519-dalek", "deno_core", @@ -4306,7 +4312,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", "pem-rfc7468", @@ -4501,7 +4507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -4513,6 +4519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -5675,7 +5682,7 @@ dependencies = [ "google-cloud-metadata", "google-cloud-token", "hex", - "hmac", + "hmac 0.12.1", "home", "jsonwebtoken 9.3.1", "path-clean", @@ -6042,7 +6049,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -6054,6 +6061,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -7709,6 +7725,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "md5" version = "0.6.1" @@ -8559,7 +8585,7 @@ dependencies = [ "chrono", "dyn-clone", "ed25519-dalek", - "hmac", + "hmac 0.12.1", "http 1.5.0", "itertools 0.10.5", "log", @@ -8916,7 +8942,7 @@ dependencies = [ "der", "des 0.8.1", "hex", - "hmac", + "hmac 0.12.1", "pkcs12", "pkcs5", "rand 0.9.5", @@ -9080,7 +9106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -9366,7 +9392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" dependencies = [ "cms", - "const-oid", + "const-oid 0.9.6", "der", "digest 0.10.7", "spki", @@ -9471,7 +9497,7 @@ dependencies = [ [[package]] name = "postgres-native-tls" version = "0.5.2" -source = "git+https://github.com/MaterializeInc/rust-postgres?rev=78c1222577bb091d69bc22b1bc7ad01c14675abe#78c1222577bb091d69bc22b1bc7ad01c14675abe" +source = "git+https://github.com/windmill-labs/rust-postgres?rev=7f4374f9237237759305942727e136742b1e7f8b#7f4374f9237237759305942727e136742b1e7f8b" dependencies = [ "native-tls", "tokio", @@ -9493,25 +9519,25 @@ dependencies = [ [[package]] name = "postgres-protocol" -version = "0.6.9" -source = "git+https://github.com/MaterializeInc/rust-postgres?rev=78c1222577bb091d69bc22b1bc7ad01c14675abe#78c1222577bb091d69bc22b1bc7ad01c14675abe" +version = "0.6.12" +source = "git+https://github.com/windmill-labs/rust-postgres?rev=7f4374f9237237759305942727e136742b1e7f8b#7f4374f9237237759305942727e136742b1e7f8b" dependencies = [ "base64 0.22.1", "byteorder", "bytes", "fallible-iterator", - "hmac", - "md-5 0.10.6", + "hmac 0.13.0", + "md-5 0.11.0", "memchr", - "rand 0.9.5", - "sha2 0.10.9", + "rand 0.10.3", + "sha2 0.11.0", "stringprep", ] [[package]] name = "postgres-types" version = "0.2.11" -source = "git+https://github.com/MaterializeInc/rust-postgres?rev=78c1222577bb091d69bc22b1bc7ad01c14675abe#78c1222577bb091d69bc22b1bc7ad01c14675abe" +source = "git+https://github.com/windmill-labs/rust-postgres?rev=7f4374f9237237759305942727e136742b1e7f8b#7f4374f9237237759305942727e136742b1e7f8b" dependencies = [ "array-init", "bit-vec 0.6.3", @@ -10471,7 +10497,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -10634,7 +10660,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -11658,6 +11684,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -12077,7 +12114,7 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa", "log", "md-5 0.10.6", @@ -12117,7 +12154,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "itoa", "log", @@ -13380,7 +13417,7 @@ dependencies = [ [[package]] name = "tokio-postgres" version = "0.7.15" -source = "git+https://github.com/MaterializeInc/rust-postgres?rev=78c1222577bb091d69bc22b1bc7ad01c14675abe#78c1222577bb091d69bc22b1bc7ad01c14675abe" +source = "git+https://github.com/windmill-labs/rust-postgres?rev=7f4374f9237237759305942727e136742b1e7f8b#7f4374f9237237759305942727e136742b1e7f8b" dependencies = [ "async-trait", "byteorder", @@ -14808,7 +14845,7 @@ dependencies = [ "gethostname", "git-version", "hex", - "hmac", + "hmac 0.12.1", "jsonwebtoken 10.4.0", "lazy_static", "once_cell", @@ -14956,7 +14993,7 @@ dependencies = [ "futures", "git-version", "hex", - "hmac", + "hmac 0.12.1", "http 1.5.0", "hyper 1.11.1", "indexmap 2.14.2", @@ -15291,7 +15328,7 @@ dependencies = [ "base64 0.22.1", "futures", "hex", - "hmac", + "hmac 0.12.1", "rand 0.9.5", "rdkafka", "reqwest 0.13.5", @@ -15640,7 +15677,7 @@ dependencies = [ "git-version", "globset", "hex", - "hmac", + "hmac 0.12.1", "hyper 1.11.1", "indexmap 2.14.2", "itertools 0.14.0", @@ -15843,7 +15880,7 @@ dependencies = [ "backon", "base64 0.22.1", "chrono", - "hmac", + "hmac 0.12.1", "http 1.5.0", "itertools 0.14.0", "lazy_static", @@ -15875,7 +15912,7 @@ dependencies = [ "base64 0.22.1", "chrono", "hex", - "hmac", + "hmac 0.12.1", "itertools 0.14.0", "lazy_static", "reqwest 0.12.28", @@ -16226,7 +16263,7 @@ dependencies = [ "futures", "futures-core", "hex", - "hmac", + "hmac 0.12.1", "itertools 0.14.0", "lazy_static", "once_cell", @@ -16519,7 +16556,7 @@ dependencies = [ "constant_time_eq 0.3.1", "futures", "hex", - "hmac", + "hmac 0.12.1", "http 1.5.0", "hyper 1.11.1", "itertools 0.14.0", @@ -16750,7 +16787,7 @@ dependencies = [ "gcp_auth", "git-version", "hex", - "hmac", + "hmac 0.12.1", "hudsucker", "hyper-http-proxy", "hyper-rustls 0.27.10", @@ -17438,7 +17475,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "spki", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d2bec22817..a17197ee9f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -249,36 +249,21 @@ sqlx-sqlite = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7" } # Use tiberius main branch for libgssapi 0.8.1 fix (https://github.com/prisma/tiberius/issues/343) tiberius = { git = "https://github.com/prisma/tiberius", rev = "59db57960a14b422fb3a1309aa4aa47880896ff8" } -# Pin tokio-postgres / postgres-types / postgres-protocol to the -# MaterializeInc fork. windmill-trigger-postgres already pulled this -# fork in transitively for the postgres-replication crate -# (CopyBothDuplex, LogicalReplicationStream, TupleData with binary -# tuple support) which upstream rust-postgres has declined to merge -# since 2021 (PR #752 → #778, both still unmerged). +# Pin tokio-postgres / postgres-types / postgres-protocol to +# windmill-labs/rust-postgres, branch `windmill-describe-first`: the +# MaterializeInc fork (postgres-replication, which upstream has not merged) +# plus `Client::describe_typed`, which pg_executor needs before +# query_typed_raw so result columns of types the client has not seen yet +# (custom enums / domains, citext, postgis) don't deadlock a large result. +# Each pinned commit carries a `windmill-*` tag so it stays reachable when the +# branch is rebased onto MaterializeInc master. # -# MI also carries a mitigation for the -# Client::query_typed_raw / Client::prepare deadlock on result columns -# whose Oid the client doesn't know about yet (citext, custom enums / -# domains, postgis): MI's 2025-12-11 PR #33 resized the per-request -# response channel from mpsc::channel(1) → mpsc::channel(1024). -# bounded(1024) is sufficient for any realistic typeinfo deferral -# (need ~2-3 batches) but leaves a theoretical failure mode at -# >~64 MB results with a custom-Oid column. The strict-correct fix is -# mpsc::unbounded(); a follow-up PR to MI is open proposing that. -# -# The [patch.crates-io] entries below force windmill-worker's -# pg_executor (which imports `tokio_postgres::` directly from -# crates.io) onto the same fork as windmill-trigger-postgres, so the -# deadlock mitigation reaches both consumers. -# -# Upstream deadlock PRs (open, not on the critical path now that MI -# is mitigated): -# https://github.com/rust-postgres/rust-postgres/pull/1348 -# https://github.com/rust-postgres/rust-postgres/pull/1349 -# Reproducer: https://github.com/rubenfiszel/tokio-postgres-deadlock-repro -tokio-postgres = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } -postgres-types = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } -postgres-protocol = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } +# The [patch.crates-io] entries force windmill-worker's pg_executor (which +# imports `tokio_postgres::` from crates.io) onto the same fork as +# windmill-trigger-postgres. +tokio-postgres = { git = "https://github.com/windmill-labs/rust-postgres", rev = "7f4374f9237237759305942727e136742b1e7f8b" } +postgres-types = { git = "https://github.com/windmill-labs/rust-postgres", rev = "7f4374f9237237759305942727e136742b1e7f8b" } +postgres-protocol = { git = "https://github.com/windmill-labs/rust-postgres", rev = "7f4374f9237237759305942727e136742b1e7f8b" } [dependencies] anyhow.workspace = true @@ -630,8 +615,8 @@ convert_case = "0.6.0" getrandom = "0.2" tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]} postgres-protocol = "0.6" -rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"} -rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } +rust-postgres = { package = "tokio-postgres", git = "https://github.com/windmill-labs/rust-postgres", rev = "7f4374f9237237759305942727e136742b1e7f8b"} +rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/windmill-labs/rust-postgres", features = ["runtime"], rev = "7f4374f9237237759305942727e136742b1e7f8b" } bit-vec = "=0.6.3" mappable-rc = "^0" mysql_async = { version = "*", default-features = false, features = ["minimal", "default", "native-tls-tls", "rust_decimal"]} diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 22a59f82e6..f2d48446bc 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -361,6 +361,79 @@ fn wrap_param_encoding_error( to_anyhow(err).into() } +/// Whether a statement may return a row stream, and so needs its column types described +/// before it runs. Anything not recognised as returning at most a row counts as a stream: +/// a wrong guess only costs a round trip, while a missed stream can hang the job. +fn can_stream_rows(query: &str) -> bool { + let Some(keyword) = leading_keyword(query) else { + return true; + }; + let rowless = matches!( + keyword.as_str(), + "insert" + | "update" + | "delete" + | "merge" + | "create" + | "alter" + | "drop" + | "truncate" + | "grant" + | "revoke" + | "comment" + | "set" + | "reset" + | "call" + | "do" + | "begin" + | "commit" + | "rollback" + | "lock" + | "vacuum" + | "analyze" + | "refresh" + ); + !rowless || query.to_ascii_lowercase().contains("returning") +} + +/// The first keyword of a statement, past whitespace and comments, following Postgres's +/// scanner: a `--` comment ends at CR or LF, and block comments nest, so +/// `/* /* a */ INSERT */ SELECT` starts with SELECT. `None` when the comments never end. +fn leading_keyword(query: &str) -> Option { + let mut rest = query; + loop { + rest = rest.trim_start(); + if let Some(after) = rest.strip_prefix("--") { + rest = &after[after.find(['\n', '\r'])?..]; + } else if rest.starts_with("/*") { + let mut depth = 0usize; + let mut i = 0; + let bytes = rest.as_bytes(); + loop { + match bytes.get(i..i + 2)? { + b"/*" => depth += 1, + b"*/" => depth -= 1, + _ => { + i += 1; + continue; + } + } + i += 2; + if depth == 0 { + break; + } + } + rest = &rest[i..]; + } else { + break; + } + } + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')) + .unwrap_or(rest.len()); + Some(rest[..end].to_ascii_lowercase()) +} + fn otyp_to_pg_type(otyp: &str) -> error::Result { let base = otyp.trim_end_matches("[]"); let is_array = otyp.ends_with("[]"); @@ -509,15 +582,25 @@ fn do_postgresql_inner<'a>( let result_f = async move { let mut res: Vec> = vec![]; - // Always prefer query_typed_raw (unnamed prepared statement). It is sent as - // a single Parse+Bind+Execute+Sync round-trip, so it survives transaction-mode - // connection poolers (PgBouncer/Supabase pooler/RDS Proxy) where named + // Always prefer query_typed_raw (unnamed prepared statement). It and its + // describe are each a self-contained round-trip ending in Sync, so they + // survive transaction-mode connection poolers (PgBouncer/Supabase + // pooler/RDS Proxy) where named // statements ("s0", "s1", ...) can be reported missing because the prepare // and the execute land on different backend connections. Fall back to // prepare + query_raw only when an arg has a type unsupported by // otyp_to_pg_type (e.g. custom enum, geometry, …) — in that case we lose // pooler safety, but the query at least runs against a direct connection. let rows = if all_types_resolved { + // query_typed_raw looks up result column types this connection has not seen + // (enums, domains, extension types) while the rows already stream, and on a + // large result that lookup waits behind them forever. Describing first resolves + // them up front, still without a named statement. + if can_stream_rows(&query) { + if let Err(e) = client.describe_typed(&query, ¶m_types).await { + return Err(wrap_param_encoding_error(e, ¶m_meta, ¶m_types)); + } + } let typed_params = query_params .iter() .zip(param_types.iter()) @@ -2510,6 +2593,39 @@ mod tests { windmill_parser_sql::parse_pg_typ(arg_t) } + #[test] + fn only_rowless_statements_skip_the_describe() { + for q in [ + "SELECT * FROM t", + "-- $1 n (int)\nselect $1", + "WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x", + "INSERT INTO t VALUES (1) RETURNING id", + "/* c */ UPDATE t SET a = 1\nreturning *", + "UPDATE t SET note = $$a;b$$ RETURNING *", + "/* /* inner */ INSERT */ SELECT * FROM t", + "/* never closed INSERT", + "-- INSERT", + "-- header\rSELECT m, pad AS\nupdate FROM t", + "insert_rows()", + "TABLE t", + "VALUES (1)", + "EXPLAIN SELECT 1", + ] { + assert!(can_stream_rows(q), "{q}"); + } + for q in [ + "INSERT INTO t VALUES (1)", + "-- $1 n (int)\nUPDATE t SET a = $1", + "delete from t", + "CREATE TABLE t (a int)", + "SET search_path TO x", + "CALL p()", + "/* a /* b */ c */\n-- d\nINSERT INTO t VALUES (1)", + ] { + assert!(!can_stream_rows(q), "{q}"); + } + } + #[test] fn convert_val_null_for_every_known_arg_t() { // `Value::Null` for every type the parser may resolve, plus an unknown From 3d1d2495567274b4c48babb374e7716c4ed24ecc Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 18:40:32 +0200 Subject: [PATCH 003/174] feat: add an instance setting routing all dependency jobs to one tag (#11486) * feat: add an instance setting routing all dependency jobs to one tag Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep bunnative locks on bun and explain the default dependency routing Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/src/main.rs | 78 +++++++++---------- backend/src/monitor.rs | 53 +++++++++---- backend/tests/dependency_job_tag.rs | 65 ++++++++++++++++ .../windmill-common/src/global_settings.rs | 1 + .../windmill-common/src/instance_config.rs | 2 + backend/windmill-common/src/scripts.rs | 4 +- backend/windmill-common/src/worker.rs | 1 + backend/windmill-queue/src/jobs.rs | 58 +++++++++++--- .../lib/components/DefaultTagsInner.svelte | 60 +++++++++++++- frontend/src/lib/consts.ts | 1 + 10 files changed, 253 insertions(+), 70 deletions(-) create mode 100644 backend/tests/dependency_job_tag.rs diff --git a/backend/src/main.rs b/backend/src/main.rs index 23978d7e62..81104d6764 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -44,28 +44,27 @@ use windmill_common::{ CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, - DISABLE_PASSWORD_LOGIN_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, - EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, - FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, + DEPENDENCY_JOB_TAG_SETTING, DISABLE_PASSWORD_LOGIN_SETTING, EMAIL_DOMAIN_SETTING, + ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, + EXTRA_PIP_INDEX_URL_SETTING, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, - NO_DEFAULT_MAVEN_SETTING, - NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, - NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, - OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, - POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, - REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, - RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, - SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, - SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, - SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, - SERVICE_LOG_RETENTION_SECS_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, - TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, - UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, + NO_DEFAULT_MAVEN_SETTING, NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, + NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, + OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, + POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, + REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, + RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_OVERRIDES_SETTING, + RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, SANDBOX_IMAGE_PULL_POLICY_SETTING, + SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, SERVICE_LOG_RETENTION_SECS_SETTING, + SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_MAX_QUEUED_JOBS_SETTING, WORKSPACE_REGISTRIES_SETTING, @@ -126,24 +125,23 @@ use windmill_worker::{ }; use crate::monitor::{ - initial_load, load_concurrency_key_max_queued, load_disable_password_login, - load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, - load_mcp_disable_token_query_param, load_metrics_debug_enabled, - load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides, - load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, - load_workspace_fairness_duration_secs, load_workspace_fairness_enabled, - load_workspace_fairness_max_percent, load_workspace_fairness_min_total, - load_workspace_max_queued_jobs, monitor_db, reload_app_workspaced_route_setting, - reload_audit_log_retention_days_setting, reload_base_url_setting, - reload_bun_install_min_release_age_setting, reload_bunfig_install_scopes_setting, - reload_critical_alert_mute_ui_setting, reload_critical_alert_mute_zombie_job_restart_setting, + initial_load, load_concurrency_key_max_queued, load_dependency_job_tag, + load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, + load_mcp_disable_token_query_param, load_metrics_debug_enabled, load_preview_tags_override, + load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled, + load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, + load_workspace_fairness_enabled, load_workspace_fairness_max_percent, + load_workspace_fairness_min_total, load_workspace_max_queued_jobs, monitor_db, + reload_app_workspaced_route_setting, reload_audit_log_retention_days_setting, + reload_base_url_setting, reload_bun_install_min_release_age_setting, + reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting, + reload_critical_alert_mute_zombie_job_restart_setting, reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting, reload_extra_pip_index_url_setting, reload_http_route_default_allowed_origins_setting, - reload_http_route_workspaced_route_setting, - reload_hub_api_secret_setting, reload_hub_base_url_setting, - reload_instance_events_webhook_setting, reload_job_default_timeout_setting, - reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, - reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting, + reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting, + reload_hub_base_url_setting, reload_instance_events_webhook_setting, + reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, + reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting, reload_nsjail_tmpfs_size_setting, reload_otel_traces_retention_secs_setting, reload_otel_tracing_proxy_setting, reload_pip_index_url_setting, reload_retention_period_setting, reload_sandbox_image_cache_max_setting, @@ -423,7 +421,6 @@ struct HubResourceTypeRaw { pub display_name: Option>, } - /// Processed resource type with parsed schema #[derive(serde::Deserialize, serde::Serialize, Clone)] pub struct HubResourceType { @@ -1889,12 +1886,10 @@ async fn process_notify_event( ); windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE.remove(payload); } - windmill_common::SCRIPT_VERSION_DELETED_CHANNEL => { - match serde_json::from_str(payload) { - Ok(deleted) => windmill_api_scripts::scripts::evict_deleted_script_versions(deleted), - Err(e) => tracing::error!("Invalid script version deletion payload {payload}: {e}"), - } - } + windmill_common::SCRIPT_VERSION_DELETED_CHANNEL => match serde_json::from_str(payload) { + Ok(deleted) => windmill_api_scripts::scripts::evict_deleted_script_versions(deleted), + Err(e) => tracing::error!("Invalid script version deletion payload {payload}: {e}"), + }, "notify_runnable_version_change" => { tracing::info!("Runnable version change detected: {}", payload); match payload.split(':').collect::>().as_slice() { @@ -2055,6 +2050,11 @@ async fn process_notify_event( tracing::error!("Error loading preview tags override: {e:#}"); } } + DEPENDENCY_JOB_TAG_SETTING => { + if let Err(e) = load_dependency_job_tag(db).await { + tracing::error!("Error loading dependency job tag: {e:#}"); + } + } WORKSPACE_FAIRNESS_ENABLED_SETTING => { if let Err(e) = load_workspace_fairness_enabled(db).await { tracing::error!("Error loading workspace fairness enabled: {e:#}"); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 751d19fecc..eafbda45c5 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -60,17 +60,17 @@ use windmill_common::{ CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, - DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_PASSWORD_LOGIN, DISABLE_PASSWORD_LOGIN_SETTING, - EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, - FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING, - HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, - JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, - MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, - MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, - NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, - OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING, - PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, - PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, + DEFAULT_TAGS_WORKSPACES_SETTING, DEPENDENCY_JOB_TAG_SETTING, DISABLE_PASSWORD_LOGIN, + DISABLE_PASSWORD_LOGIN_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, + EXTRA_PIP_INDEX_URL_SETTING, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, + HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, + JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, + KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM, + MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, + NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, + NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, + OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, + POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, @@ -93,11 +93,11 @@ use windmill_common::{ load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env, load_worker_config, store_pull_query, store_suspended_pull_query, Connection, WorkerConfig, CLOUD_HOSTED, CONCURRENCY_KEY_MAX_QUEUED, CONCURRENCY_KEY_MAX_QUEUED_DEFAULT, - DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, - INDEXER_CONFIG, PREVIEW_TAGS_OVERRIDE, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG, - WORKER_GROUP, WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED, - WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_MAX_QUEUED_JOBS, - WORKSPACE_MAX_QUEUED_JOBS_DEFAULT, + DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, DEPENDENCY_JOB_TAG, + FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG, PREVIEW_TAGS_OVERRIDE, SMTP_CONFIG, + WINDMILL_DIR, WORKER_CONFIG, WORKER_GROUP, WORKSPACE_FAIRNESS_DURATION_SECS, + WORKSPACE_FAIRNESS_ENABLED, WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL, + WORKSPACE_MAX_QUEUED_JOBS, WORKSPACE_MAX_QUEUED_JOBS_DEFAULT, }, KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, @@ -312,6 +312,9 @@ pub async fn initial_load( pass.setting(PREVIEW_TAGS_OVERRIDE_SETTING, false, |v| async move { apply_preview_tags_override(v) }); + pass.setting(DEPENDENCY_JOB_TAG_SETTING, false, |v| async move { + apply_dependency_job_tag(v) + }); // Load per-workspace retention overrides before the first cleanup tick so a fresh server // never sweeps globally without honoring configured longer-retention workspaces. @@ -876,6 +879,24 @@ pub fn apply_preview_tags_override(value: Option) { } } +pub async fn load_dependency_job_tag(db: &DB) -> error::Result<()> { + let v = load_value_from_global_settings(db, DEPENDENCY_JOB_TAG_SETTING).await?; + apply_dependency_job_tag(v); + Ok(()) +} + +pub fn apply_dependency_job_tag(value: Option) { + let tag = match value { + Some(serde_json::Value::String(t)) if !t.trim().is_empty() => Some(t.trim().to_string()), + None | Some(serde_json::Value::Null) | Some(serde_json::Value::String(_)) => None, + Some(other) => { + tracing::error!("{DEPENDENCY_JOB_TAG_SETTING} is not a string: {other}, ignoring"); + return; + } + }; + DEPENDENCY_JOB_TAG.store(std::sync::Arc::new(tag)); +} + // Upper bound on the duration window. Postgres `make_interval(secs => $1::int4)` is the consumer // downstream, so this stays comfortably below `i32::MAX` and the subsequent `u32 -> i32` cast in // `workspace_fairness::refresh_overloaded` cannot wrap into a negative interval (which would diff --git a/backend/tests/dependency_job_tag.rs b/backend/tests/dependency_job_tag.rs new file mode 100644 index 0000000000..f7e89ef83e --- /dev/null +++ b/backend/tests/dependency_job_tag.rs @@ -0,0 +1,65 @@ +//! Sets the process-wide `DEPENDENCY_JOB_TAG`, so it lives in its own test binary: any other +//! test queuing a dependency job in the same process would land on the overridden tag. + +use sqlx::{Pool, Postgres}; +use windmill_common::worker::DEPENDENCY_JOB_TAG; +use windmill_dep_map::scoped_dependency_map::DependencyDependent; + +#[sqlx::test(fixtures("base"))] +async fn relocks_go_to_the_configured_dependency_job_tag(db: Pool) -> anyhow::Result<()> { + DEPENDENCY_JOB_TAG.store(std::sync::Arc::new(Some("deps-$workspace".to_string()))); + + for (path, hash, tag) in [ + ("f/tags/tagged", 7_100_001_i64, Some("default-$workspace")), + ("f/tags/plain", 7_100_002_i64, None), + ] { + sqlx::query( + "INSERT INTO script(workspace_id, created_by, content, schema, summary, description, path, hash, language, tag) + VALUES ('test-workspace', 'test-user', 'def main(): pass', '{}', '', '', $1, $2, 'python3', $3)", + ) + .bind(path) + .bind(hash) + .bind(tag) + .execute(&db) + .await?; + } + let importers = ["f/tags/tagged", "f/tags/plain"] + .map(|p| DependencyDependent { + importer_path: p.to_string(), + importer_kind: "script".to_string(), + importer_node_ids: None, + }) + .to_vec(); + windmill_dep_map::trigger_dependents::trigger_dependents_to_recompute_dependencies( + "test-workspace", + importers, + None, + None, + "test@windmill.dev", + "test-user", + "u/test-user", + &db, + vec![], + ) + .await?; + + let tags: Vec<(String, String)> = sqlx::query_as( + "SELECT runnable_path, tag FROM v2_job WHERE kind = 'dependencies' ORDER BY runnable_path", + ) + .fetch_all(&db) + .await?; + assert_eq!( + tags, + vec![ + ( + "f/tags/plain".to_string(), + "deps-test-workspace".to_string() + ), + ( + "f/tags/tagged".to_string(), + "deps-test-workspace".to_string() + ), + ] + ); + Ok(()) +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 9137dc364e..61c83e311f 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -9,6 +9,7 @@ pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces"; pub const FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING: &str = "fork_workspace_tag_append_fork_suffix"; pub const PREVIEW_TAGS_OVERRIDE_SETTING: &str = "preview_tags_override"; +pub const DEPENDENCY_JOB_TAG_SETTING: &str = "dependency_job_tag"; pub const BASE_URL_SETTING: &str = "base_url"; pub const WS_BASE_URL_SETTING: &str = "ws_base_url"; pub const OAUTH_SETTING: &str = "oauths"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index fd790547b6..1bb323ed3d 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -275,6 +275,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub auto_build_binary_tag: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub dependency_job_tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub ws_base_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub github_app_webhook_base_url: Option, diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 9726065c6f..49d82db787 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -28,7 +28,9 @@ use crate::utils::StripPath; /// `push`'s default (the language tag). Every relock of a script must use this, or a relock /// lands on a worker pool other than the one its deploy locked on and resolves packages /// differently. -/// Dedicated workers are handled by `push`, which gives `dedicated_worker` precedence. +/// Dedicated workers are handled by `push`, which gives `dedicated_worker` precedence. Below +/// that, `push` replaces this result with the instance's `dependency_job_tag` setting when it is +/// set, except for bunnative. pub fn dependency_job_tag(tag: Option, language: &ScriptLang) -> Option { if tag.as_ref().is_some_and(|x| x.contains("$args[")) { None diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index ca1745a34d..98a26aac25 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -450,6 +450,7 @@ lazy_static::lazy_static! { pub static ref DEFAULT_TAGS_WORKSPACES: arc_swap::ArcSwap>> = arc_swap::ArcSwap::from_pointee(None); pub static ref FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX: AtomicBool = AtomicBool::new(false); pub static ref PREVIEW_TAGS_OVERRIDE: AtomicBool = AtomicBool::new(false); + pub static ref DEPENDENCY_JOB_TAG: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); pub static ref MAX_TIMEOUT: u64 = std::env::var("TIMEOUT") .ok() diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index c2c42c649c..19be82d66f 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -81,9 +81,9 @@ use windmill_common::{ users::{SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL}, utils::{not_found_if_none, report_critical_error, StripPath, WarnAfterExt}, worker::{ - to_raw_value, CLOUD_HOSTED, DISABLE_FLOW_SCRIPT, NO_LOGS, PREVIEW_TAGS_OVERRIDE, - WORKER_PULL_QUERIES, WORKER_PULL_QUERIES_FAIRNESS, WORKER_SUSPENDED_PULL_QUERY, - WORKSPACE_FAIRNESS_OVERLOADED, + to_raw_value, CLOUD_HOSTED, DEPENDENCY_JOB_TAG, DISABLE_FLOW_SCRIPT, NO_LOGS, + PREVIEW_TAGS_OVERRIDE, WORKER_PULL_QUERIES, WORKER_PULL_QUERIES_FAIRNESS, + WORKER_SUSPENDED_PULL_QUERY, WORKSPACE_FAIRNESS_OVERLOADED, }, DB, METRICS_ENABLED, }; @@ -4619,15 +4619,27 @@ pub async fn pull( && !(job.kind.is_preview() && PREVIEW_TAGS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed)) { - let effective_ws = per_workspace_tag(&job.workspace_id, db).await; - let base_tag = if job.is_flow() { - "flow".to_string() - } else { - "dependency".to_string() - }; - let tag = match &effective_ws { - Some(ws) => format!("{}-{}", base_tag, ws), - None => base_tag, + let tag = match configured_dependency_job_tag( + &job.kind, + job.script_lang.as_ref(), + &job.workspace_id, + db, + ) + .await + { + Some(tag) => tag, + None => { + let effective_ws = per_workspace_tag(&job.workspace_id, db).await; + let base_tag = if job.is_flow() { + "flow".to_string() + } else { + "dependency".to_string() + }; + match &effective_ws { + Some(ws) => format!("{}-{}", base_tag, ws), + None => base_tag, + } + } }; sqlx::query!( "UPDATE v2_job_queue SET tag = $1, running = false WHERE id = $2", @@ -5275,6 +5287,24 @@ pub async fn resolve_push_tag( Some(interpolate_args(tag.to_string(), args, &tag_ws)) } +/// The tag a dependency job is routed to when the instance's `dependency_job_tag` setting is +/// set, whatever tag the script, flow or app itself runs on. `None` for any other job kind, for +/// bunnative (its bundle must be built on a worker with the bun tag), or when the setting is +/// unset. +pub async fn configured_dependency_job_tag( + job_kind: &JobKind, + language: Option<&ScriptLang>, + workspace_id: &str, + db: &DB, +) -> Option { + if !job_kind.is_dependency() || language == Some(&ScriptLang::Bunnative) { + return None; + } + let tag = DEPENDENCY_JOB_TAG.load_full(); + let tag = tag.as_deref()?; + resolve_push_tag(tag, &PushArgs::from(&HashMap::new()), workspace_id, db).await +} + /// Refuses a `tag` the caller chose that the instance's custom tags do not let `w_id` use, /// judging the queue it resolves to. `args` must be the ones the job is pushed with: resolving /// with any others checks a queue the job does not land on. @@ -7152,6 +7182,10 @@ async fn push_inner<'c, 'd>( runnable_path.clone().expect("dedicated script has a path") ); windmill_common::worker::dedicated_worker_tag(workspace_id, &full_path) + } else if let Some(tag) = + configured_dependency_job_tag(&job_kind, language.as_ref(), workspace_id, db).await + { + tag } else { let interpolated_tag = match tag { Some(x) => resolve_push_tag(&x, &args, workspace_id, db).await, diff --git a/frontend/src/lib/components/DefaultTagsInner.svelte b/frontend/src/lib/components/DefaultTagsInner.svelte index 9e890696ec..87cfa0f89b 100644 --- a/frontend/src/lib/components/DefaultTagsInner.svelte +++ b/frontend/src/lib/components/DefaultTagsInner.svelte @@ -8,8 +8,10 @@ DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, - PREVIEW_TAGS_OVERRIDE_SETTING + PREVIEW_TAGS_OVERRIDE_SETTING, + DEPENDENCY_JOB_TAG_SETTING } from '$lib/consts' + import TextInput from './text_input/TextInput.svelte' import Toggle from './Toggle.svelte' import MultiSelect from './select/MultiSelect.svelte' import { safeSelectItems } from './select/utils.svelte' @@ -31,12 +33,14 @@ let limitToWorkspaces = $state(false) let previewTagsOverride = $state(false) let forkAppendForkSuffix = $state(false) + let dependencyJobTag = $state('') // Change detection let originalDefaultTagPerWorkspace = $state(defaultTagPerWorkspace) let originalDefaultTagWorkspaces = $state(defaultTagWorkspaces) let originalPreviewTagsOverride = $state(false) let originalForkAppendForkSuffix = $state(false) + let originalDependencyJobTag = $state('') // Detect changes let hasChanges = $derived( @@ -44,7 +48,8 @@ JSON.stringify($state.snapshot(originalDefaultTagWorkspaces)?.sort() || []) !== JSON.stringify($state.snapshot(defaultTagWorkspaces)?.sort() || []) || originalPreviewTagsOverride !== previewTagsOverride || - originalForkAppendForkSuffix !== forkAppendForkSuffix + originalForkAppendForkSuffix !== forkAppendForkSuffix || + originalDependencyJobTag.trim() !== dependencyJobTag.trim() ) let workspaces: string[] = $state([]) @@ -70,6 +75,11 @@ })) as any forkAppendForkSuffix = forkSetting ?? false originalForkAppendForkSuffix = forkAppendForkSuffix + dependencyJobTag = + ((await SettingService.getGlobal({ + key: DEPENDENCY_JOB_TAG_SETTING + })) as any) ?? '' + originalDependencyJobTag = dependencyJobTag } catch (err) { sendUserToast(`Could not load default tags: ${err}`, true) } @@ -104,11 +114,19 @@ } }) + await SettingService.setGlobal({ + key: DEPENDENCY_JOB_TAG_SETTING, + requestBody: { + value: dependencyJobTag.trim() || undefined + } + }) + // Update original state after save originalDefaultTagPerWorkspace = defaultTagPerWorkspace originalDefaultTagWorkspaces = [...(defaultTagWorkspaces || [])] originalPreviewTagsOverride = previewTagsOverride originalForkAppendForkSuffix = forkAppendForkSuffix + originalDependencyJobTag = dependencyJobTag loadDefaultTags() sendUserToast('Saved') @@ -218,6 +236,33 @@ disabled={!$enterpriseLicense} /> +
+ Dependency job tag + + Route every dependency job (lockfile resolution at deploy and relocks triggered by an + imported script changing, for scripts, flows and apps) to this tag, whatever tag the + runnable itself runs on. Supports $workspace. Dedicated workers still build + their own locks, and Bun native scripts still build their bundle on the bun tag. + + + Leave empty for the default routing: a script's dependency job runs on the script's own + tag, or on its language's default tag (e.g. {defaultTagPerWorkspace ? 'python3-$workspace' : 'python3'}) when it has none; flow and app dependency jobs run on + {defaultTagPerWorkspace ? 'dependency-$workspace' : 'dependency'}. + Worker groups + + +
@@ -240,6 +285,17 @@
{/each} + {#if dependencyJobTag.trim()} +
+
+ all dependency jobs +
+
→
+
+ {dependencyJobTag.trim()} +
+
+ {/if} {#if previewTagsOverride}
diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index 11d801543c..c6e18427f0 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -47,6 +47,7 @@ export const DEFAULT_TAGS_PER_WORKSPACE_SETTING = 'default_tags_per_workspace' export const DEFAULT_TAGS_WORKSPACES_SETTING = 'default_tags_workspaces' export const FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING = 'fork_workspace_tag_append_fork_suffix' export const PREVIEW_TAGS_OVERRIDE_SETTING = 'preview_tags_override' +export const DEPENDENCY_JOB_TAG_SETTING = 'dependency_job_tag' export const WORKSPACE_FAIRNESS_ENABLED_SETTING = 'workspace_fairness_enabled' export const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING = 'workspace_fairness_max_percent' From 4bc7e0d7bad22e9e1a3532ca747ed863f90213ba Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 18:59:37 +0200 Subject: [PATCH 004/174] feat: add a cancel-only jobs:cancel token scope, optionally path-scoped (#11479) * feat: add a cancel-only jobs:cancel token scope, optionally path-scoped jobs:cancel grants the four cancel routes (cancel, force cancel, cancel selection, cancel persistent) and nothing else; jobs:write keeps covering them. With paths, the handlers only cancel a job whose own runnable path, or a parent flow's, matches; others get the invisible-job NotFound. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: confine cancel_selection like the by-id cancel routes A token that also carries a path-scoped jobs:run scope is confined to those runnables on the by-id cancels (through the job read check), so apply the same run confinement to each selected job. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: confine persistent cancels, admit agent runs, batch selection checks cancel_persistent applies the run-scope confinement the other cancel routes apply; a path-scoped jobs:cancel admits agent runs under the agent's path, recognized as the run-scope read check does; cancel_selection checks the cancel scope in one query. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: let a jobs:cancel grant stand on its own on the cancel routes Intersecting cancels with the token's jobs:run scopes did not hold: the token could mint itself a child carrying only the cancel scope. The cancel routes now apply the cancel paths and the usual per-job visibility, and leave the run-scope read confinement to reads, as jobs:write does. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...828c4a877709c3c39a530d629cc91f9dd2d29.json | 29 +++ backend/windmill-api-auth/src/lib.rs | 1 + backend/windmill-api-auth/src/scopes.rs | 161 ++++++++++++++++- backend/windmill-api/src/jobs.rs | 170 +++++++++++++++++- backend/windmill-api/src/token.rs | 5 + 5 files changed, 358 insertions(+), 8 deletions(-) create mode 100644 backend/.sqlx/query-35f960e9de880fdcaf81615933c828c4a877709c3c39a530d629cc91f9dd2d29.json diff --git a/backend/.sqlx/query-35f960e9de880fdcaf81615933c828c4a877709c3c39a530d629cc91f9dd2d29.json b/backend/.sqlx/query-35f960e9de880fdcaf81615933c828c4a877709c3c39a530d629cc91f9dd2d29.json new file mode 100644 index 0000000000..db2af83b47 --- /dev/null +++ b/backend/.sqlx/query-35f960e9de880fdcaf81615933c828c4a877709c3c39a530d629cc91f9dd2d29.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE chain(origin, id, parent_job) AS (\n SELECT id, id, parent_job FROM v2_job WHERE id = ANY($1) AND workspace_id = $2\n UNION ALL\n SELECT c.origin, j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT c.origin AS \"origin!\",\n CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\\.chat$', '')\n ELSE j.runnable_path END AS \"runnable_path!\"\n FROM chain c JOIN v2_job j ON j.id = c.id,\n LATERAL (SELECT j.kind = 'flowpreview'\n AND j.raw_flow->'modules'->1 IS NULL\n AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a\n WHERE j.workspace_id = $2 AND j.runnable_path IS NOT NULL\n AND (a.agent OR j.kind IN ('script', 'script_hub', 'unassigned_script', 'flow',\n 'unassigned_flow', 'singlestepflow', 'unassigned_singlestepflow'))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "origin!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "runnable_path!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "35f960e9de880fdcaf81615933c828c4a877709c3c39a530d629cc91f9dd2d29" +} diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 12ad5f2654..5b6a618e5f 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -625,6 +625,7 @@ fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool // Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an // app-editor token can mint the narrower run-only credential. ("write", "run") if caller.domain == "apps" => {} + ("write", "cancel") if caller.domain == "jobs" => {} _ => return false, } diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index e100cf88c6..f2a6612e42 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -124,6 +124,7 @@ impl ScopeDefinition { // running its components. Not general — `jobs:write` must not grant // `jobs:run`. The resource check below still confines it to the same app. ("write", "run") if self.domain == "apps" => {} + ("write", "cancel") if self.domain == "jobs" => {} _ => return false, } @@ -430,9 +431,10 @@ impl ScopeDomain { /// Available scope actions #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ScopeAction { - Read, // GET operations, list, view - Write, // POST, PUT, PATCH, DELETE operations, create, update, delete - Run, // Special action for running (scripts, flows, etc.) + Read, // GET operations, list, view + Write, // POST, PUT, PATCH, DELETE operations, create, update, delete + Run, // Special action for running (scripts, flows, etc.) + Cancel, // Cancelling jobs (`CANCEL_PATH_ACTIONS`); covered by `jobs:write` } impl ScopeAction { @@ -441,6 +443,7 @@ impl ScopeAction { Self::Read => "read", Self::Write => "write", Self::Run => "run", + Self::Cancel => "cancel", } } @@ -450,6 +453,7 @@ impl ScopeAction { "write" => Some(Self::Write), "delete" => Some(Self::Write), "run" => Some(Self::Run), + "cancel" => Some(Self::Cancel), _ => None, } } @@ -460,6 +464,7 @@ impl ScopeAction { match (self, other) { (ScopeAction::Write, ScopeAction::Read) => true, (ScopeAction::Run, ScopeAction::Read) => true, + (ScopeAction::Write, ScopeAction::Cancel) => true, (a, b) => a == b, } } @@ -636,7 +641,38 @@ lazy_static::lazy_static! { }; } +/// The job routes a `jobs:cancel` scope reaches, as workspaced route suffixes. A +/// path-scoped `jobs:cancel:` is resource-blind here like every scope; the +/// handlers confine it through `job_cancel_path_confinement`, so a cancel route added +/// here without that check would serve a path-scoped token every job. +const CANCEL_PATH_ACTIONS: [&'static str; 4] = [ + "jobs_u/queue/cancel/", + "jobs_u/queue/force_cancel/", + "jobs_u/queue/cancel_persistent/", + "jobs/queue/cancel_selection", +]; + +fn is_cancel_route(method: &str, route_path: &str) -> bool { + if !method.eq_ignore_ascii_case("POST") { + return false; + } + let mut parts = route_path.splitn(5, '/'); + let (Some(""), Some("api"), Some("w"), Some(_), Some(suffix)) = ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) else { + return false; + }; + CANCEL_PATH_ACTIONS.iter().any(|p| suffix.starts_with(p)) +} + fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction { + if is_cancel_route(method, route_path) { + return ScopeAction::Cancel; + } if RUN_PATH_ACTIONS .iter() .any(|run_path| route_path.contains(run_path)) @@ -974,6 +1010,9 @@ pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option { confinement.push(scope) } + // Grants no reads (see `scope_grants_access`), so it neither confines nor + // frees them. + Some(ScopeAction::Cancel) => continue, Some(_) => return None, None => continue, } @@ -997,6 +1036,58 @@ pub fn run_confinement_admits( confinement.iter().any(|scope| scope.includes(&required)) } +/// The `jobs:cancel:` scopes a token's cancels are confined to, or `None` when +/// they are not confined: an unscoped token, or one whose cancel grant is not +/// path-scoped (`jobs:cancel`, or `jobs:write`, which stays resource-blind here as on +/// every other job write). +/// +/// A job is within the confinement when its own `runnable_path` or that of any of its +/// `parent_job` ancestors matches (see `cancel_confinement_admits`), so a token scoped +/// to a flow can cancel the flow's steps. +pub fn job_cancel_path_confinement(scopes: Option<&[String]>) -> Option> { + let mut confinement = Vec::new(); + for scope in scopes? + .iter() + .filter(|s| !s.starts_with("if_jobs:filter_tags:")) + { + let Ok(scope) = ScopeDefinition::from_scope_string(scope) else { + continue; + }; + if ScopeDomain::from_str(&scope.domain) != Some(ScopeDomain::Jobs) { + continue; + } + match ScopeAction::from_str(&scope.action) { + Some(ScopeAction::Cancel) if scope.resource.is_some() => confinement.push(scope), + Some(ScopeAction::Cancel | ScopeAction::Write) => return None, + _ => continue, + } + } + (!confinement.is_empty()).then_some(confinement) +} + +/// Whether the token holds a `jobs:cancel` scope, path-scoped or not. +pub fn has_job_cancel_grant(scopes: Option<&[String]>) -> bool { + scopes.is_some_and(|scopes| { + scopes.iter().any(|s| { + ScopeDefinition::from_scope_string(s).is_ok_and(|s| { + ScopeDomain::from_str(&s.domain) == Some(ScopeDomain::Jobs) + && ScopeAction::from_str(&s.action) == Some(ScopeAction::Cancel) + }) + }) + }) +} + +/// Whether a job of `runnable_path` is inside a [`job_cancel_path_confinement`] set. +pub fn cancel_confinement_admits(confinement: &[ScopeDefinition], runnable_path: &str) -> bool { + let required = ScopeDefinition::new( + ScopeDomain::Jobs.as_str(), + ScopeAction::Cancel.as_str(), + None, + Some(vec![runnable_path.to_string()]), + ); + confinement.iter().any(|scope| scope.includes(&required)) +} + fn scope_grants_access( scope: &ScopeDefinition, required_domain: ScopeDomain, @@ -1781,4 +1872,68 @@ mod tests { // Unknown route -> None so the caller fails closed. assert!(scope_for_route("GET", "/healthz").is_none()); } + + #[test] + fn jobs_cancel_grants_only_the_cancel_routes() { + let cancel = vec!["jobs:cancel:f/served/*".to_string()]; + let id = "0190f4c2-0000-7000-8000-000000000000"; + for route in [ + format!("/api/w/ws/jobs_u/queue/cancel/{id}"), + format!("/api/w/ws/jobs_u/queue/force_cancel/{id}"), + "/api/w/ws/jobs_u/queue/cancel_persistent/f/served/s".to_string(), + "/api/w/ws/jobs/queue/cancel_selection".to_string(), + ] { + assert!( + check_route_access(&cancel, &route, "POST").is_ok(), + "{route}" + ); + assert!( + check_route_access(&["jobs:write".to_string()], &route, "POST").is_ok(), + "{route}" + ); + assert!( + check_route_access(&["jobs:read".to_string()], &route, "POST").is_err(), + "{route}" + ); + } + for (route, method) in [ + (format!("/api/w/ws/jobs_u/get/{id}"), "GET"), + ("/api/w/ws/jobs/list".to_string(), "GET"), + (format!("/api/w/ws/jobs/flow/resume_suspended/{id}"), "POST"), + (format!("/api/w/ws/jobs/queue/run_now/{id}"), "POST"), + ("/api/w/ws/jobs/run/p/f/served/s".to_string(), "POST"), + ] { + assert!( + check_route_access(&cancel, &route, method).is_err(), + "{route}" + ); + } + } + + #[test] + fn jobs_cancel_path_confinement() { + let scopes = |s: &[&str]| s.iter().map(|s| s.to_string()).collect::>(); + let conf = + job_cancel_path_confinement(Some(&scopes(&["jobs:cancel:f/served/*,u/svc/kill_me"]))) + .unwrap(); + assert!(cancel_confinement_admits(&conf, "f/served/etl")); + assert!(cancel_confinement_admits(&conf, "u/svc/kill_me")); + assert!(!cancel_confinement_admits(&conf, "f/other/etl")); + assert!(!cancel_confinement_admits(&conf, "f/served_not/etl")); + + // A cancel grant that is not path-scoped leaves cancels unconfined. + for s in [ + &["jobs:cancel"][..], + &["jobs:cancel:f/a/*", "jobs:write"], + &[], + ] { + assert!( + job_cancel_path_confinement(Some(&scopes(s))).is_none(), + "{s:?}" + ); + } + // A cancel scope neither confines nor frees a run token's reads. + let run = scopes(&["jobs:run:flows:f/a/b", "jobs:cancel"]); + assert!(job_read_run_confinement(Some(&run)).is_some()); + } } diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 785c6ff935..00139215c4 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -19,7 +19,7 @@ use serde_json::value::RawValue; use serde_json::Value; use sha2::{Digest, Sha256}; use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; use std::time::Instant; @@ -620,7 +620,7 @@ async fn cancel_job_api( // right to kill someone else's run. Anonymous callers are instead confined to // anonymous-created jobs by `cancel_job`'s `require_anonymous`. if let Some(authed) = opt_authed.as_ref() { - require_job_update_read_access(&db, &user_db, authed, &w_id, &id, None).await?; + require_job_cancel_access(&db, &user_db, authed, &w_id, &id).await?; } let tx = db.begin().await?; @@ -689,7 +689,15 @@ async fn cancel_persistent_script_api( Json(CancelJob { reason }): Json, ) -> error::Result<()> { let audit_author: AuditAuthor = match opt_authed { - Some(authed) => (&authed).into(), + Some(authed) => { + let path = script_path.to_path(); + if !windmill_api_auth::scopes::job_cancel_path_confinement(authed.scopes.as_deref()) + .is_none_or(|c| windmill_api_auth::scopes::cancel_confinement_admits(&c, path)) + { + return Err(Error::NotFound(format!("Script {path} not found"))); + } + (&authed).into() + } None => { return Err(Error::BadRequest(format!( "Cancelling persistent script require to be logged in and member of {w_id}" @@ -782,7 +790,7 @@ async fn force_cancel( // caller who can only see an inner step kill a root flow hidden from them. if let Some(authed) = opt_authed.as_ref() { let target = force_cancel_target(&db, &w_id, id).await?; - require_job_update_read_access(&db, &user_db, authed, &w_id, &target, None).await?; + require_job_cancel_access(&db, &user_db, authed, &w_id, &target).await?; } let tx = db.begin().await?; @@ -1705,6 +1713,22 @@ pub(crate) async fn require_job_read_access( job_id: &Uuid, created_by: &str, view_token: Option<&str>, +) -> error::Result<()> { + require_job_access( + db, user_db, authed, w_id, job_id, created_by, view_token, true, + ) + .await +} + +async fn require_job_access( + db: &DB, + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + job_id: &Uuid, + created_by: &str, + view_token: Option<&str>, + run_confined: bool, ) -> error::Result<()> { // Tag scope (`if_jobs:filter_tags:`) is an orthogonal hard restriction on a // scoped token: it must never read a job outside its allowed tags, regardless of @@ -1732,7 +1756,9 @@ pub(crate) async fn require_job_read_access( // A path-scoped `jobs:run` token is likewise hard-restricted to the runnables it // may start, ahead of every grant below — the token is handed out to run one thing, // so it must not read jobs of anything else merely because its owner could. - require_job_within_run_scope(db, authed, w_id, job_id).await?; + if run_confined { + require_job_within_run_scope(db, authed, w_id, job_id).await?; + } // Fast path: you can always read a job you launched. This is also load-bearing // for apps — a component job runs as the app policy's `permissioned_as`, but its @@ -1961,6 +1987,73 @@ async fn require_job_within_run_scope( } } +/// The `ids` a path-scoped `jobs:cancel:` token may cancel; all of them for +/// every caller whose cancels are not path-confined (see `job_cancel_path_confinement`). +/// +/// A job is admitted when it, or any of its `parent_job` ancestors, is a run of a +/// deployed script, flow or agent whose path the scope names: cancelling a flow's step +/// is within a scope on the flow. Only those kinds count, because a preview's +/// `runnable_path` is whatever its caller sent and would otherwise let any preview +/// impersonate an in-scope runnable. Agent runs are previews filed under the agent's +/// path, recognized the same way `require_job_within_run_scope` does. +async fn filter_jobs_within_cancel_scope( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + ids: Vec, +) -> error::Result> { + let Some(confinement) = + windmill_api_auth::scopes::job_cancel_path_confinement(authed.scopes.as_deref()) + else { + return Ok(ids); + }; + let chain = sqlx::query!( + r#"WITH RECURSIVE chain(origin, id, parent_job) AS ( + SELECT id, id, parent_job FROM v2_job WHERE id = ANY($1) AND workspace_id = $2 + UNION ALL + SELECT c.origin, j.id, j.parent_job FROM v2_job j + JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2 + ) + SELECT c.origin AS "origin!", + CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\.chat$', '') + ELSE j.runnable_path END AS "runnable_path!" + FROM chain c JOIN v2_job j ON j.id = c.id, + LATERAL (SELECT j.kind = 'flowpreview' + AND j.raw_flow->'modules'->1 IS NULL + AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a + WHERE j.workspace_id = $2 AND j.runnable_path IS NOT NULL + AND (a.agent OR j.kind IN ('script', 'script_hub', 'unassigned_script', 'flow', + 'unassigned_flow', 'singlestepflow', 'unassigned_singlestepflow'))"#, + &ids, + w_id, + ) + .fetch_all(db) + .await?; + let admitted: HashSet = chain + .into_iter() + .filter(|r| { + windmill_api_auth::scopes::cancel_confinement_admits(&confinement, &r.runnable_path) + }) + .map(|r| r.origin) + .collect(); + Ok(ids.into_iter().filter(|id| admitted.contains(id)).collect()) +} + +async fn require_job_within_cancel_scope( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + job_id: Uuid, +) -> error::Result<()> { + if filter_jobs_within_cancel_scope(db, authed, w_id, vec![job_id]) + .await? + .is_empty() + { + return Err(Error::NotFound(format!("Job {job_id} not found"))); + } + Ok(()) +} + /// Self + every `parent_job` ancestor (intermediate sub-flows up to the top-level /// root) of `job_id`, resolved via the root DB (flow lineage is not sensitive). /// Falls back to `[job_id]` if the row is absent so callers still run their probe. @@ -2156,6 +2249,41 @@ async fn require_job_update_read_access( require_job_read_access(db, user_db, authed, w_id, job_id, &created_by, view_token).await } +/// The per-job check of the cancel routes. A `jobs:cancel` grant stands on its own, like +/// `jobs:write`: its paths confine it (`filter_jobs_within_cancel_scope`), not the +/// token's `jobs:run` scopes, which bound what a run token may read. Intersecting the two +/// would not hold anyway, since the token can mint itself a child holding only the +/// cancel scope. +async fn require_job_cancel_access( + db: &DB, + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + job_id: &Uuid, +) -> error::Result<()> { + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2", + job_id, + w_id, + ) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?; + let run_confined = !windmill_api_auth::scopes::has_job_cancel_grant(authed.scopes.as_deref()); + require_job_access( + db, + user_db, + authed, + w_id, + job_id, + &created_by, + None, + run_confined, + ) + .await?; + require_job_within_cancel_scope(db, authed, w_id, *job_id).await +} + /// Whether a validated approval token should grant the job-read bypass. The token alone /// is sufficient unless the current approval step has `user_auth_required`, in which case /// only an authorized approver may read the job (and thus its args/flow inputs). @@ -4372,6 +4500,38 @@ async fn cancel_selection( let force_cancel = query.force_cancel.unwrap_or(false); let mut cancelled = Vec::new(); for (workspace_id, ids) in jobs_by_workspace { + let ids = + if windmill_api_auth::scopes::job_cancel_path_confinement(authed.scopes.as_deref()) + .is_some() + { + // Checked on the job the cancel actually kills: a force cancel reaches the + // highest queued ancestor. + let mut targets = Vec::with_capacity(ids.len()); + for id in ids { + let target = if force_cancel { + force_cancel_target(&db, &workspace_id, id).await? + } else { + id + }; + targets.push((id, target)); + } + let admitted: HashSet = filter_jobs_within_cancel_scope( + &db, + &authed, + &workspace_id, + targets.iter().map(|(_, t)| *t).collect(), + ) + .await? + .into_iter() + .collect(); + targets + .into_iter() + .filter(|(_, t)| admitted.contains(t)) + .map(|(id, _)| id) + .collect() + } else { + ids + }; let Json(mut w_cancelled) = cancel_jobs( ids, &db, diff --git a/backend/windmill-api/src/token.rs b/backend/windmill-api/src/token.rs index a1a9206196..53dfb2a214 100644 --- a/backend/windmill-api/src/token.rs +++ b/backend/windmill-api/src/token.rs @@ -200,6 +200,11 @@ lazy_static! { label: "Write".to_string(), requires_resource_path: false, }, + ScopeOption { + value: "jobs:cancel".to_string(), + label: "Cancel".to_string(), + requires_resource_path: true, + }, ScopeOption { value: "jobs:run:scripts".to_string(), label: "Run scripts".to_string(), From 4beb1f9420524425c354e82729e4e35c5bf65451 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 19:02:17 +0200 Subject: [PATCH 005/174] feat: add trigger, tag, run-as and digest claims to job OIDC tokens (#11481) * feat: add trigger, tag, run-as and digest claims to job OIDC tokens Co-Authored-By: Claude Opus 5.5 (1M context) * fix: parse digest floats exactly and derive the codebase digest like push Co-Authored-By: Claude Opus 5.5 (1M context) * fix: give flows that reference flow nodes no digest and keep --json clean Co-Authored-By: Claude Opus 5.5 (1M context) * fix: list flow step digests from module positions only Co-Authored-By: Claude Opus 5.5 (1M context) * test: skip the pulled module-script digest check on windows Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to 71b8c1042fd8188c2d2882476f12b671cb5ba421 This commit updates the EE repository reference after PR #840 was merged in windmill-ee-private. Previous ee-repo-ref: ba1870536789a43c5b6ec18522a93edb9cb07f3d New ee-repo-ref: 71b8c1042fd8188c2d2882476f12b671cb5ba421 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...247270c9044b363b71496ba36e1357c63a2a4.json | 155 +++++++++++++ ...64af7f33464879bef4d1eff682126dffeef64.json | 125 ---------- backend/Cargo.lock | 1 + backend/Cargo.toml | 3 +- backend/ee-repo-ref.txt | 2 +- backend/tests/job_provenance.rs | 101 ++++++++ backend/windmill-common/Cargo.toml | 1 + backend/windmill-common/src/item_digest.rs | 132 +++++++++++ backend/windmill-common/src/job_provenance.rs | 96 +++++++- backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/oidc_oss.rs | 6 + cli/src/commands/digest/digest.ts | 219 ++++++++++++++++++ cli/src/commands/flow/flow.ts | 52 +++-- cli/src/guidance/skills.gen.ts | 9 + cli/src/main.ts | 2 + cli/test/fixtures/item_digest_vectors.json | 67 ++++++ cli/test/item_digest_unit.test.ts | 88 +++++++ .../auto-generated/cli/cli-commands.md | 9 + system_prompts/auto-generated/prompts.ts | 9 + .../skills/cli-commands/SKILL.md | 9 + 20 files changed, 933 insertions(+), 154 deletions(-) create mode 100644 backend/.sqlx/query-76440a1c5b40a897078817cc960247270c9044b363b71496ba36e1357c63a2a4.json delete mode 100644 backend/.sqlx/query-ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64.json create mode 100644 backend/windmill-common/src/item_digest.rs create mode 100644 cli/src/commands/digest/digest.ts create mode 100644 cli/test/fixtures/item_digest_vectors.json create mode 100644 cli/test/item_digest_unit.test.ts diff --git a/backend/.sqlx/query-76440a1c5b40a897078817cc960247270c9044b363b71496ba36e1357c63a2a4.json b/backend/.sqlx/query-76440a1c5b40a897078817cc960247270c9044b363b71496ba36e1357c63a2a4.json new file mode 100644 index 0000000000..8eecbfa7bc --- /dev/null +++ b/backend/.sqlx/query-76440a1c5b40a897078817cc960247270c9044b363b71496ba36e1357c63a2a4.json @@ -0,0 +1,155 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n tag, permissioned_as, args, 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, p.tag, p.permissioned_as, p.args, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path, runnable_id,\n trigger_kind::text AS trigger_kind, trigger, tag AS \"tag!\",\n permissioned_as AS \"permissioned_as!\",\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2\n AND NOT s.deleted)\n ELSE true\n END AS \"origin_verified!\",\n -- What `run/f` and `run/p` resolve the path to now; for scripts the predicate of\n -- `get_latest_deployed_script_hash`.\n COALESCE(CASE kind\n WHEN 'flow' THEN runnable_id = (SELECT f.versions[array_upper(f.versions, 1)]\n FROM flow f WHERE f.path = runnable_path AND f.workspace_id = $2)\n WHEN 'script' THEN runnable_id = (SELECT s.hash FROM script s\n WHERE s.path = runnable_path AND s.workspace_id = $2 AND NOT s.deleted\n AND s.lock IS NOT NULL AND s.lock_error_logs IS NULL\n ORDER BY s.created_at DESC LIMIT 1)\n END, false) AS \"current_version!\",\n EXISTS (SELECT 1 FROM v2_job_status st WHERE st.id = lineage.id\n AND jsonb_typeof(st.flow_status->'restarted_from') = 'object')\n OR EXISTS (SELECT 1 FROM v2_job_completed c WHERE c.id = lineage.id\n AND jsonb_typeof(c.flow_status->'restarted_from') = 'object')\n AS \"restarted!\",\n COALESCE(\n (SELECT st.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_status st\n WHERE st.id = lineage.id),\n (SELECT c.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_completed c\n WHERE c.id = lineage.id)\n ) AS restarted_from,\n -- Only deployed-app runs are stamped with their app; an app editor preview\n -- runs app code at an app path it does not have to own.\n COALESCE(trigger_kind = 'app' AND starts_with(runnable_path, trigger || '/'), false)\n AS \"app_stamped!\",\n -- A preview's modules come from its args, which its parent may have taken from\n -- the caller.\n COALESCE(jsonb_typeof(args->'_MODULES') = 'object', false) AS \"args_modules!\",\n CASE WHEN depth = 0 THEN (SELECT wp.worker_group FROM v2_job_queue q\n JOIN worker_ping wp ON wp.worker = q.worker WHERE q.id = lineage.id)\n END AS worker_group,\n CASE WHEN depth = 0 THEN CASE\n WHEN starts_with(permissioned_as, 'g/') THEN 'group'\n ELSE (SELECT CASE WHEN u.is_service_account THEN 'service_account' ELSE 'user' END\n FROM usr u WHERE starts_with(permissioned_as, 'u/')\n AND u.username = substr(permissioned_as, 3) AND u.workspace_id = $2)\n END END AS run_as_type,\n -- The canonical item `item_digest` hashes, null members included.\n CASE WHEN depth = 0 OR depth = max(depth) OVER () THEN CASE kind\n WHEN 'script' THEN (SELECT jsonb_build_object('content', s.content,\n 'lock', s.lock, 'codebase', s.codebase,\n 'modules', (SELECT jsonb_object_agg(m.key, m.value - 'language')\n FROM jsonb_each(s.modules) m))\n FROM script s WHERE s.hash = runnable_id AND s.workspace_id = $2\n AND NOT s.deleted)\n WHEN 'flow' THEN (SELECT fv.value FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.workspace_id = $2)\n WHEN 'flowscript' THEN (SELECT jsonb_build_object('content', n.code,\n 'lock', n.lock)\n FROM flow_node n WHERE n.id = runnable_id AND n.workspace_id = $2)\n END END AS digest_source\n FROM lineage ORDER BY depth", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "kind!: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "runnable_id", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "trigger_kind", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "tag!", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "permissioned_as!", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "origin_verified!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "current_version!", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "restarted!", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "restarted_from", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "app_stamped!", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "args_modules!", + "type_info": "Bool" + }, + { + "ordinal": 15, + "name": "worker_group", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "run_as_type", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "digest_source", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "76440a1c5b40a897078817cc960247270c9044b363b71496ba36e1357c63a2a4" +} diff --git a/backend/.sqlx/query-ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64.json b/backend/.sqlx/query-ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64.json deleted file mode 100644 index 3c52f8ba96..0000000000 --- a/backend/.sqlx/query-ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n permissioned_as, args, 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, p.permissioned_as, p.args, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path, runnable_id,\n trigger_kind::text AS trigger_kind, permissioned_as AS \"permissioned_as!\",\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2\n AND NOT s.deleted)\n ELSE true\n END AS \"origin_verified!\",\n -- What `run/f` and `run/p` resolve the path to now; for scripts the predicate of\n -- `get_latest_deployed_script_hash`.\n COALESCE(CASE kind\n WHEN 'flow' THEN runnable_id = (SELECT f.versions[array_upper(f.versions, 1)]\n FROM flow f WHERE f.path = runnable_path AND f.workspace_id = $2)\n WHEN 'script' THEN runnable_id = (SELECT s.hash FROM script s\n WHERE s.path = runnable_path AND s.workspace_id = $2 AND NOT s.deleted\n AND s.lock IS NOT NULL AND s.lock_error_logs IS NULL\n ORDER BY s.created_at DESC LIMIT 1)\n END, false) AS \"current_version!\",\n EXISTS (SELECT 1 FROM v2_job_status st WHERE st.id = lineage.id\n AND jsonb_typeof(st.flow_status->'restarted_from') = 'object')\n OR EXISTS (SELECT 1 FROM v2_job_completed c WHERE c.id = lineage.id\n AND jsonb_typeof(c.flow_status->'restarted_from') = 'object')\n AS \"restarted!\",\n COALESCE(\n (SELECT st.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_status st\n WHERE st.id = lineage.id),\n (SELECT c.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_completed c\n WHERE c.id = lineage.id)\n ) AS restarted_from,\n -- Only deployed-app runs are stamped with their app; an app editor preview\n -- runs app code at an app path it does not have to own.\n COALESCE(trigger_kind = 'app' AND starts_with(runnable_path, trigger || '/'), false)\n AS \"app_stamped!\",\n -- A preview's modules come from its args, which its parent may have taken from\n -- the caller.\n COALESCE(jsonb_typeof(args->'_MODULES') = 'object', false) AS \"args_modules!\"\n FROM lineage ORDER BY depth", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "kind!: JobKind", - "type_info": { - "Custom": { - "name": "job_kind", - "kind": { - "Enum": [ - "script", - "preview", - "flow", - "dependencies", - "flowpreview", - "script_hub", - "identity", - "flowdependencies", - "http", - "graphql", - "postgresql", - "noop", - "appdependencies", - "deploymentcallback", - "singlestepflow", - "flowscript", - "flownode", - "appscript", - "aiagent", - "unassigned_script", - "unassigned_flow", - "unassigned_singlestepflow" - ] - } - } - } - }, - { - "ordinal": 3, - "name": "runnable_path", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "runnable_id", - "type_info": "Int8" - }, - { - "ordinal": 5, - "name": "trigger_kind", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "permissioned_as!", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "origin_verified!", - "type_info": "Bool" - }, - { - "ordinal": 8, - "name": "current_version!", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "restarted!", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "restarted_from", - "type_info": "Text" - }, - { - "ordinal": 11, - "name": "app_stamped!", - "type_info": "Bool" - }, - { - "ordinal": 12, - "name": "args_modules!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7f660b6b51..10f5a71687 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15711,6 +15711,7 @@ dependencies = [ "rsa", "rustls 0.23.35", "rustls-native-certs 0.8.4", + "ryu-js", "schemars 0.8.22", "semver 1.0.28", "serde", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a17197ee9f..263cb62907 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -458,7 +458,7 @@ tower-cookies = "^0.11" serde = "^1" # 1.0.151 introduced RawValue::from_string_unchecked, which the SQL executors use # to avoid re-parsing every collected row. -serde_json = { version = "^1.0.151", features = ["preserve_order", "raw_value"] } +serde_json = { version = "^1.0.151", features = ["preserve_order", "raw_value", "float_roundtrip"] } serde_yml = "0.0.12" uuid = { version = "^1", features = ["serde", "v4", "js"] } thiserror = "^2" @@ -571,6 +571,7 @@ base64 = "^0.22.1" base32 = "^0" hmac = "0.12.1" sha2 = "0.10.6" +ryu-js = "1.0.3" md-5 = "0.10.6" sha1 = "0.10.6" sqlx = { version = "0.8.0", features = [ diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index c2c253e8f0..0d0bb78414 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7 +71b8c1042fd8188c2d2882476f12b671cb5ba421 diff --git a/backend/tests/job_provenance.rs b/backend/tests/job_provenance.rs index 42ccf829e6..4eda4e5e17 100644 --- a/backend/tests/job_provenance.rs +++ b/backend/tests/job_provenance.rs @@ -150,3 +150,104 @@ async fn flow_steps_are_latest_only_under_a_current_unrestarted_flow(db: Pool) { + use serde_json::{json, Value}; + use windmill_common::job_provenance::RunAsType; + + let v: Value = + serde_json::from_str(include_str!("../../cli/test/fixtures/item_digest_vectors.json")) + .unwrap(); + let (script, flow) = (&v["script"], &v["flow"]); + + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, content, lock, modules, language, kind, created_by, schema, summary, description) + VALUES ('test-workspace', 777, $1, $2, $3, $4, 'bun', 'script', 'test-user', '{}', '', '')", + ) + .bind(script["path"].as_str()) + .bind(script["content"].as_str()) + .bind(script["lock"].as_str()) + .bind(&script["modules"]) + .execute(&db) + .await + .unwrap(); + + // Version 889 holds step `a` by reference, which no checkout can reproduce. + let step = &flow["value"]["modules"][0]["value"]; + let mut by_ref = flow["value"].clone(); + by_ref["modules"][0]["value"] = json!({ + "type": "flowscript", "id": 999, "language": step["language"], + "input_transforms": step["input_transforms"], + }); + sqlx::query( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, versions) + VALUES ('test-workspace', $1, '', '', $2, 'test-user', ARRAY[888::bigint, 889::bigint])", + ) + .bind(flow["path"].as_str()) + .bind(&flow["value"]) + .execute(&db) + .await + .unwrap(); + for (id, value) in [(888, &flow["value"]), (889, &by_ref)] { + sqlx::query( + "INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by) + VALUES ($1, 'test-workspace', $2, $3, '{}', 'test-user')", + ) + .bind(id as i64) + .bind(flow["path"].as_str()) + .bind(value) + .execute(&db) + .await + .unwrap(); + } + sqlx::query( + "INSERT INTO flow_node (id, workspace_id, path, code, lock, hash_v2) + VALUES (999, 'test-workspace', $1, $2, $3, 'h')", + ) + .bind(flow["path"].as_str()) + .bind(step["content"].as_str()) + .bind(step["lock"].as_str()) + .execute(&db) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, parent_job, trigger, trigger_kind, tag, created_by, permissioned_as, permissioned_as_email) + VALUES + ('3bb0c0de-0000-4000-8000-000000000201', 'test-workspace', 'script', $1, 777, NULL, NULL, NULL, 'bun', 'test-user', 'g/all', 'group-all@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000202', 'test-workspace', 'flow', $2, 888, NULL, 'f/digest/nightly', 'schedule', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000203', 'test-workspace', 'flowscript', $2 || '/a', 999, '3bb0c0de-0000-4000-8000-000000000202', NULL, NULL, 'gpu', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000204', 'test-workspace', 'flow', $2, 889, NULL, NULL, NULL, 'flow', 'test-user', 'u/test-user', 'test@windmill.dev')", + ) + .bind(script["path"].as_str()) + .bind(flow["path"].as_str()) + .execute(&db) + .await + .unwrap(); + for q in [ + "INSERT INTO worker_ping (worker, worker_instance, worker_group) VALUES ('wk-gpu', 'wk', 'gpu-group')", + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, worker, tag) + VALUES ('3bb0c0de-0000-4000-8000-000000000203', 'test-workspace', now(), true, 'wk-gpu', 'gpu')", + ] { + sqlx::query(q).execute(&db).await.unwrap(); + } + + let s = provenance(&db, "3bb0c0de-0000-4000-8000-000000000201").await; + assert_eq!(s.digest.as_deref(), script["digest"].as_str()); + assert_eq!(s.root_digest, s.digest); + assert_eq!(s.run_as_type, RunAsType::Group); + assert_eq!(s.worker_group, None); + + let step = provenance(&db, "3bb0c0de-0000-4000-8000-000000000203").await; + assert_eq!(step.digest.as_deref(), flow["steps"]["a"].as_str()); + assert_eq!(step.root_digest.as_deref(), flow["digest"].as_str()); + assert_eq!(step.root_trigger.as_deref(), Some("f/digest/nightly")); + assert_eq!(step.tag, "gpu"); + assert_eq!(step.worker_group.as_deref(), Some("gpu-group")); + assert_eq!(step.run_as_type, RunAsType::User); + + assert_eq!(provenance(&db, "3bb0c0de-0000-4000-8000-000000000204").await.digest, None); +} diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 96fa7ceb63..1a8027c149 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -33,6 +33,7 @@ path = "src/lib.rs" tar.workspace = true hmac.workspace = true sha2.workspace = true +ryu-js.workspace = true sha1.workspace = true thiserror.workspace = true anyhow.workspace = true diff --git a/backend/windmill-common/src/item_digest.rs b/backend/windmill-common/src/item_digest.rs new file mode 100644 index 0000000000..23110bcae7 --- /dev/null +++ b/backend/windmill-common/src/item_digest.rs @@ -0,0 +1,132 @@ +//! Content digests of deployed scripts and flows, reproducible with `wmill digest` +//! (`cli/src/commands/digest/digest.ts`) from a `wmill sync` checkout pulled after the +//! deployment's dependency jobs: they write the locks, and store loops and branches with +//! their defaults filled in. The two implementations must hash the same bytes: change one +//! only together with the other. Numbers hash as the doubles they parse to, which needs +//! serde_json's `float_roundtrip` to parse them as exactly as JavaScript does. +//! +//! A digest is the lowercase hex SHA-256 of the canonical JSON (RFC 8785) of the item, with +//! every object member whose value is null removed: +//! - a script: `{"content", "lock", "modules", "codebase"}`, where `modules` is the +//! multi-file script's `{: {"content", "lock"}}` and is left out when empty. +//! The language is not covered: a checkout only records it in the file extension; +//! - a flow step's inline script: `{"content", "lock"}`; +//! - a flow: its `value`. A flow whose value references code in `flow_node` has none, see +//! [`references_flow_nodes`]. + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::flows::{Branch, FlowModule, FlowModuleValue, FlowValue, ToolValue}; + +pub fn digest(value: &Value) -> String { + let mut out = String::new(); + write_canonical(value, &mut out); + format!("{:x}", Sha256::digest(out.as_bytes())) +} + +pub fn canonical_json(value: &Value) -> String { + let mut out = String::new(); + write_canonical(value, &mut out); + out +} + +fn write_canonical(value: &Value, out: &mut String) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + // RFC 8785 serializes every number as an IEEE double, the way ECMAScript prints it. + Value::Number(n) => { + let f = n.as_f64().unwrap_or(0.0); + out.push_str(ryu_js::Buffer::new().format(f)) + } + Value::String(s) => out.push_str(&serde_json::to_string(s).unwrap_or_default()), + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_canonical(item, out); + } + out.push(']'); + } + Value::Object(members) => { + // RFC 8785 orders members by the UTF-16 code units of their names. + let mut members: Vec<_> = members.iter().filter(|(_, v)| !v.is_null()).collect(); + members.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16())); + out.push('{'); + for (i, (k, v)) in members.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&serde_json::to_string(k).unwrap_or_default()); + out.push(':'); + write_canonical(v, out); + } + out.push('}'); + } + } +} + +/// Whether a flow value runs code stored outside it, in `flow_node` rows: an inline script +/// step `{"type": "flowscript", "id"}`, or a loop's or branch's steps in a `*_node`. Its digest +/// would not cover that code, and a checkout cannot reproduce it either, since the export +/// carries the references. A value or step that does not parse runs nothing, so it counts as +/// none. +pub fn references_flow_nodes(value: &Value) -> bool { + let Ok(flow) = serde_json::from_value::(value.clone()) else { + return false; + }; + flow.modules + .iter() + .chain(flow.preprocessor_module.as_deref()) + .chain(flow.failure_module.as_deref()) + .any(module_references_flow_nodes) +} + +fn module_references_flow_nodes(module: &FlowModule) -> bool { + module + .get_value() + .is_ok_and(|v| module_value_references_flow_nodes(&v)) +} + +fn module_value_references_flow_nodes(value: &FlowModuleValue) -> bool { + let any = |modules: &[FlowModule]| modules.iter().any(module_references_flow_nodes); + let branches = |branches: &[Branch]| { + branches + .iter() + .any(|b| b.modules_node.is_some() || any(&b.modules)) + }; + match value { + FlowModuleValue::FlowScript { .. } => true, + FlowModuleValue::ForloopFlow { modules, modules_node, .. } + | FlowModuleValue::WhileloopFlow { modules, modules_node, .. } => { + modules_node.is_some() || any(modules) + } + FlowModuleValue::BranchOne { branches: b, default, default_node } => { + default_node.is_some() || any(default) || branches(b) + } + FlowModuleValue::BranchAll { branches: b, .. } => branches(b), + FlowModuleValue::AIAgent { tools, .. } => tools.iter().any(|tool| match &tool.value { + ToolValue::FlowModule(v) => module_value_references_flow_nodes(v), + _ => false, + }), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn canonical_form_follows_rfc_8785_without_nulls() { + let v = json!({"b": [1.0, 1e21, 0.1, -0.0, null], "a": null, "é": "\u{1f}\"\n", "\u{e000}": 1, "\u{1f600}": 2}); + assert_eq!( + canonical_json(&v), + "{\"b\":[1,1e+21,0.1,0,null],\"é\":\"\\u001f\\\"\\n\",\"\u{1f600}\":2,\"\u{e000}\":1}" + ); + } +} diff --git a/backend/windmill-common/src/job_provenance.rs b/backend/windmill-common/src/job_provenance.rs index 63075b0c37..96db3b8a87 100644 --- a/backend/windmill-common/src/job_provenance.rs +++ b/backend/windmill-common/src/job_provenance.rs @@ -1,6 +1,7 @@ +use serde_json::Value; use uuid::Uuid; -use crate::{db::DB, error::Result, jobs::JobKind, scripts::ScriptHash}; +use crate::{db::DB, error::Result, item_digest, jobs::JobKind, scripts::ScriptHash}; /// Where a job's code comes from, as far as its chain of parents can prove it. pub struct JobProvenance { @@ -28,7 +29,39 @@ pub struct JobProvenance { pub root_path: Option, pub root_kind: JobKind, pub root_trigger_kind: Option, + /// What triggered the root job, as recorded in `v2_job.trigger`. + pub root_trigger: Option, pub root_version: Option, + /// See [`item_digest`]. Only scripts, flows and flow steps' inline scripts have one. + pub digest: Option, + pub root_digest: Option, + /// The job's worker tag. + pub tag: String, + /// The group of the worker running the job, if it is running. + pub worker_group: Option, + pub run_as_type: RunAsType, +} + +/// What `permissioned_as` names. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum RunAsType { + User, + ServiceAccount, + Group, + /// No member of the workspace: a superadmin outside it, a built-in identity + /// (`superadmin_secret@windmill.dev`, ...) or a user since removed. + NonMember, +} + +impl RunAsType { + pub fn as_str(&self) -> &'static str { + match self { + RunAsType::User => "user", + RunAsType::ServiceAccount => "service_account", + RunAsType::Group => "group", + RunAsType::NonMember => "non_member", + } + } } #[derive(Debug, PartialEq, Eq)] @@ -58,6 +91,8 @@ struct LineageJob { runnable_path: Option, runnable_id: Option, trigger_kind: Option, + trigger: Option, + tag: String, permissioned_as: String, origin_verified: bool, current_version: bool, @@ -66,6 +101,10 @@ struct LineageJob { restarted_from: Option, app_stamped: bool, args_modules: bool, + worker_group: Option, + run_as_type: Option, + /// What the digest hashes, for the job and the root only. + digest_source: Option, } /// Reads any job of `w_id` regardless of the caller: authorize access to `job_id` first. @@ -74,16 +113,17 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result
{/if} - {#if script?.schema?.prompt_for_ai !== undefined} + {#if Object.keys(script?.schema?.properties ?? {}).length > 0} { - onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`) - }} + instructions={(script?.schema?.prompt_for_ai as string | undefined) ?? ''} + onEditInstructions={can_write && !actingUser?.operator + ? () => onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`) + : undefined} runnableType="script" path={script?.path} /> diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index 922e5b2cfd..d607da6ac1 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -92,8 +92,8 @@ let activeAdvancedOptions = $derived([ { - name: 'Fill flow inputs with AI', - active: typeof flowStore.val.schema?.prompt_for_ai == 'string' + name: 'Additional prompt for AI', + active: !!flowStore.val.schema?.prompt_for_ai }, { name: 'High Priority', diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index c45089c723..246b0dbc0a 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -406,6 +406,10 @@ Unless the user asked for new code, look for a workspace script or flow that alr The step's `input_transforms` must cover the reused item's inputs, so read its input schema first. +## Additional Prompt for AI + +A flow's input schema may carry a top-level `prompt_for_ai` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema. + ## Organizing Flows: Groups and Notes Groups and notes shape how a flow reads in the editor; neither changes what it does. diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 5c77b89d92..c659bcab12 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -8,6 +8,7 @@ export const SCRIPT_BASE = `# Windmill Script Writing Guide - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform +- A script's input schema may carry a top-level \`prompt_for_ai\` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values @@ -432,6 +433,10 @@ Unless the user asked for new code, look for a workspace script or flow that alr The step's \`input_transforms\` must cover the reused item's inputs, so read its input schema first. +## Additional Prompt for AI + +A flow's input schema may carry a top-level \`prompt_for_ai\` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema. + ## Organizing Flows: Groups and Notes Groups and notes shape how a flow reads in the editor; neither changes what it does. diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 46ae438164..0517cf1897 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -6,6 +6,7 @@ - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index d259948e56..29f79a2ca5 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -494,6 +494,10 @@ Unless the user asked for new code, look for a workspace script or flow that alr The step's `input_transforms` must cover the reused item's inputs, so read its input schema first. Find candidates in the local tree (a `.script.yaml` sits next to each script and holds its input schema, a `flow.yaml` in each flow folder) and on the workspace with `wmill script list` / `wmill flow list`; `wmill script get ` and `wmill flow get ` show an item's details. +## Additional Prompt for AI + +A flow's input schema may carry a top-level `prompt_for_ai` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema. + ## Organizing Flows: Groups and Notes Groups and notes shape how a flow reads in the editor; neither changes what it does. diff --git a/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md b/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md index e6683248db..9bb87cb4a1 100644 --- a/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md index 42bbd7bc01..0c4634b775 100644 --- a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md index dc3e19c0c9..497336b001 100644 --- a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 6d82183b88..4b340c0db1 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index fd6b21dc60..03d40ab044 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md index d755483abb..389c683f7c 100644 --- a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index eec2f25dfa..b15655a6da 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md index b2b841d0eb..2ffcf4607c 100644 --- a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-go/SKILL.md b/system_prompts/auto-generated/skills/write-script-go/SKILL.md index 31a60a7548..1707fcda4c 100644 --- a/system_prompts/auto-generated/skills/write-script-go/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-go/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md index 9924e72f7f..c928dea3bc 100644 --- a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-java/SKILL.md b/system_prompts/auto-generated/skills/write-script-java/SKILL.md index a28d7763cb..a768a5faa5 100644 --- a/system_prompts/auto-generated/skills/write-script-java/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-java/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md index 75f3827440..9215f06906 100644 --- a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md index c2f034c4f6..d682cce351 100644 --- a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-php/SKILL.md b/system_prompts/auto-generated/skills/write-script-php/SKILL.md index 1052944b8e..16c68834dc 100644 --- a/system_prompts/auto-generated/skills/write-script-php/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-php/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md index 8937c078f4..b0e14a6b12 100644 --- a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md index c3e2bd7500..540776c68a 100644 --- a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index b030ff4316..b9c2793f47 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md b/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md index 626b602749..061e1dcc45 100644 --- a/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md index 0f4a95bbe2..d9b599dffa 100644 --- a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md index 99af11e419..8c438e7038 100644 --- a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md @@ -56,6 +56,7 @@ Use `wmill resource-type list --schema` to discover available resource types. - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index b0a8998604..5c5c93411d 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -414,6 +414,10 @@ The step's `input_transforms` must cover the reused item's inputs, so read its i Find candidates in the local tree (a `.script.yaml` sits next to each script and holds its input schema, a `flow.yaml` in each flow folder) and on the workspace with `wmill script list` / `wmill flow list`; `wmill script get ` and `wmill flow get ` show an item's details. +## Additional Prompt for AI + +A flow's input schema may carry a top-level `prompt_for_ai` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema. + ## Organizing Flows: Groups and Notes Groups and notes shape how a flow reads in the editor; neither changes what it does. diff --git a/system_prompts/base/script-base.md b/system_prompts/base/script-base.md index 2641d7cd0a..9bd878ea9b 100644 --- a/system_prompts/base/script-base.md +++ b/system_prompts/base/script-base.md @@ -6,6 +6,7 @@ - Libraries are installed automatically - do not show installation instructions - In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments - Where the language has a Windmill client (`wmill`), use it to interact with the platform +- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema ## Return Values From e7fc1b2e2e9b358b414d0ca0eaf2753e142dded7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 3 Oct 2026 09:33:44 +0200 Subject: [PATCH 016/174] feat: restricted job tokens per script and flow (#11484) * feat: restricted job tokens (job_token_scopes on scripts and flows) Co-Authored-By: Claude Opus 5.5 (1M context) * fix: admit flow-run reads, skip dedicated workers, gate on worker version Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep restricted jobs off flow runners, preserve scopes on rename and promotion Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep restricted jobs off every dedicated handoff, confine progress flow id Co-Authored-By: Claude Opus 5.5 (1M context) * fix: exclude restricted runnables from dedicated worker startup Co-Authored-By: Claude Opus 5.5 (1M context) * fix: gate restrictions on the release after 1.821.0 Co-Authored-By: Claude Opus 5.5 (1M context) * fix: store per-job scopes on job_perms instead of v2_job, pin inline runs to the checked version Co-Authored-By: Claude Opus 5.5 (1M context) * feat: step-level job_token_scopes for flow steps and agent tools Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: fail closed on perms read errors, refuse restricted queue imports, gate step scopes in previews Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: carry a job's scopes on its completion so a re-run keeps the caller's cap Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: carry a zombie job's scopes into its completion Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: leave a zombie for the next sweep when its scopes cannot be read Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * docs: correct the QueuedJobV2 completion comment Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: validate step scopes in batch flows, fail closed on unvalidated step scopes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: refuse flows with step or tool restrictions at push while an older worker is live Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: apply the step-scope worker gate to flow restarts Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: list the job token toggle with the other step and flow settings Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * chore: pin the EE companion merged with EE main Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * perf: skip scope lookups for unrestricted jobs; list job token setting last Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * style: rustfmt scopes tests Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * fix: confine restricted job tokens to their own run lineage; drop remaining extra lookups Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL * chore: update ee-repo-ref to 259ad3bfeef5285ba80eedc86309b11dca001220 This commit updates the EE repository reference after PR #843 was merged in windmill-ee-private. Previous ee-repo-ref: 2b77c0225dca441235daf7bf0a06ba968df0c927 New ee-repo-ref: 259ad3bfeef5285ba80eedc86309b11dca001220 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...cfca8913ba1129fdf4978a8a4738689786160.json | 19 + ...64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json | 152 ++++ ...7b17d6bb71f0ae69cd218e6a3fbc151d19d87.json | 23 + ...10a00333bbf3b8b43bbccc7a6e1926d82478c.json | 22 + ...d7ebd10a38cbe5724a554a12291c287b67f6d.json | 324 ++++++++ ...4585d9c2aba36fdf33bca9739049366bed107.json | 84 ++ ...96c69f22d9ea9dc3586099553a8f0e76aad82.json | 22 + ...fea97aec4d6d3ec65d3e7402811c7dddbbf64.json | 16 + ...d3abc2f67827597ef74fef473e309b1951544.json | 253 ++++++ ...b6c87f2994fa2d5f33e0f3ee9effe28614abe.json | 23 + ...cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json | 331 ++++++++ ...51cbd7b8896984115d55318bb6f4100d72d9a.json | 24 + ...aa7f5755da9997419c4a9d07b30d4da318bea.json | 15 + ...20e1e07f7e50df82fb6c33f79e6faa5b77bab.json | 29 + ...cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json} | 10 +- ...f391947d5fc3da90ef98a7b793c2430eb7bc.json} | 10 +- ...64eb6af24e8d8aa99b846f21f2ed49c320bdc.json | 16 + ...8b8517561f5b55a30e44801d6a9123a762584.json | 23 + ...3007c6aeccf76bfd07384ffaccf50a24d13d4.json | 102 +++ ...28d84f1ca3c91de86d520dce06ac12d557bd5.json | 24 + ...affc87853ff2d97f567a8817260cfef43e00e.json | 65 ++ ...7099be925f1058c76c6af31e8a620607cf6a1.json | 30 + ...878286dcc2f23101c2e1c8c1d07b6f0deaecd.json | 159 ++++ ...737b3b4b662a89148cf6ac70eb3563ad7f0c5.json | 15 + ...d10fc10d9ec4dc2359f43d79fe255d101cad8.json | 15 + ...c26f01e117b75ab48e197c7d91d388865da74.json | 398 ++++++++++ ...084f90b3ac06139ffca98670215956150563a.json | 28 + backend/ee-repo-ref.txt | 2 +- .../20261002124918_job_token_scopes.down.sql | 3 + .../20261002124918_job_token_scopes.up.sql | 3 + backend/src/monitor.rs | 21 +- backend/summarized_schema.txt | 6 +- backend/tests/asset_trigger_dispatch.rs | 3 + backend/tests/batch_rerun.rs | 2 + backend/tests/bun_jobs.rs | 1 + backend/tests/caller_modules_strip.rs | 2 + backend/tests/dedicated_workers.rs | 38 + backend/tests/dependency_job_tag.rs | 1 + backend/tests/dependency_map.rs | 1 + backend/tests/error_handler.rs | 3 + backend/tests/flow_engine_parity.rs | 2 + backend/tests/job_payload.rs | 11 + backend/tests/job_token_scopes.rs | 354 +++++++++ backend/tests/list_jobs.rs | 3 + backend/tests/nativets_stress.rs | 1 + backend/tests/preview_native_tag.rs | 1 + backend/tests/python_jobs.rs | 1 + backend/tests/restart_at_step_api.rs | 4 + backend/tests/restart_flow_version_scope.rs | 1 + backend/tests/success_handler.rs | 1 + backend/tests/worker.rs | 9 + backend/windmill-api-auth/src/auth.rs | 40 +- backend/windmill-api-auth/src/lib.rs | 133 ++-- backend/windmill-api-auth/src/scopes.rs | 573 +++----------- backend/windmill-api-flows/src/flows.rs | 68 +- backend/windmill-api-jobs/src/execution.rs | 13 +- backend/windmill-api-jobs/src/job_metrics.rs | 23 + backend/windmill-api-jobs/src/jobs_export.rs | 21 + backend/windmill-api-scripts/src/scripts.rs | 58 +- .../src/datatable_migrations.rs | 2 + .../windmill-api-workspaces/src/workspaces.rs | 9 +- .../src/workspaces_extra.rs | 4 +- backend/windmill-api/openapi.yaml | 30 + backend/windmill-api/src/agent_runs.rs | 2 + backend/windmill-api/src/ai_evals/run.rs | 2 + backend/windmill-api/src/apps.rs | 7 + backend/windmill-api/src/apps_raw_bundle.rs | 2 + .../windmill-api/src/concurrency_groups.rs | 1 + backend/windmill-api/src/jobs.rs | 99 ++- backend/windmill-api/src/mcp/core.rs | 9 +- backend/windmill-api/src/offboarding.rs | 4 +- backend/windmill-api/src/users.rs | 4 +- backend/windmill-api/src/workspaces_export.rs | 5 +- backend/windmill-common/src/auth.rs | 32 +- backend/windmill-common/src/jobs.rs | 8 +- backend/windmill-common/src/lib.rs | 12 +- backend/windmill-common/src/min_version.rs | 4 + backend/windmill-common/src/scopes.rs | 748 ++++++++++++++++++ backend/windmill-common/src/scripts.rs | 6 +- backend/windmill-common/src/worker.rs | 2 +- .../src/trigger_dependents.rs | 1 + backend/windmill-queue/src/asset_dispatch.rs | 2 + backend/windmill-queue/src/jobs.rs | 187 +++-- backend/windmill-queue/src/schedule.rs | 8 +- backend/windmill-queue/tests/debounce_test.rs | 2 + .../windmill-queue/tests/native_retry_test.rs | 34 + backend/windmill-queue/tests/schedule_push.rs | 1 + backend/windmill-test-utils/src/lib.rs | 3 + .../windmill-trigger/src/trigger_helpers.rs | 1 + backend/windmill-types/src/flows.rs | 29 + backend/windmill-types/src/jobs.rs | 4 + backend/windmill-types/src/scripts.rs | 14 +- backend/windmill-worker/src/ai/tools.rs | 10 + backend/windmill-worker/src/ai_executor.rs | 5 + backend/windmill-worker/src/bun_executor.rs | 5 + backend/windmill-worker/src/common.rs | 1 + backend/windmill-worker/src/worker.rs | 38 +- backend/windmill-worker/src/worker_flow.rs | 35 +- .../windmill-worker/src/worker_lockfiles.rs | 1 + cli/src/commands/flow/flow.ts | 17 +- cli/src/commands/script/script.ts | 4 + cli/src/guidance/skills.gen.ts | 2 +- cli/windmill-utils-internal/src/deploy.ts | 7 + docs/auth-surface.md | 23 + .../src/lib/components/FlowBuilder.svelte | 8 +- .../components/JobTokenScopesSetting.svelte | 89 +++ .../src/lib/components/ScriptBuilder.svelte | 8 +- .../flows/content/FlowRunSettings.svelte | 10 + .../flows/content/FlowSettings.svelte | 9 + .../flows/header/FlowYamlEditor.svelte | 3 + frontend/src/lib/components/flows/types.ts | 1 + .../src/lib/components/flows/utils.svelte.ts | 4 + .../components/scripts/WacExportDrawer.svelte | 1 + .../components/settings/ScopeSelector.svelte | 7 +- frontend/src/lib/utils_draft_deploy.ts | 1 + openflow.openapi.yaml | 12 + system_prompts/auto-generated/flow.md | 2 +- system_prompts/auto-generated/prompts.ts | 2 +- .../auto-generated/skills/write-flow/SKILL.md | 2 +- 119 files changed, 4511 insertions(+), 709 deletions(-) create mode 100644 backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json create mode 100644 backend/.sqlx/query-095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json create mode 100644 backend/.sqlx/query-10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87.json create mode 100644 backend/.sqlx/query-13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c.json create mode 100644 backend/.sqlx/query-333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d.json create mode 100644 backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json create mode 100644 backend/.sqlx/query-4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82.json create mode 100644 backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json create mode 100644 backend/.sqlx/query-5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544.json create mode 100644 backend/.sqlx/query-60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe.json create mode 100644 backend/.sqlx/query-6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json create mode 100644 backend/.sqlx/query-67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a.json create mode 100644 backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json create mode 100644 backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json rename backend/.sqlx/{query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json => query-83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json} (56%) rename backend/.sqlx/{query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json => query-aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc.json} (76%) create mode 100644 backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json create mode 100644 backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json create mode 100644 backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json create mode 100644 backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json create mode 100644 backend/.sqlx/query-c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e.json create mode 100644 backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json create mode 100644 backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json create mode 100644 backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json create mode 100644 backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json create mode 100644 backend/.sqlx/query-eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74.json create mode 100644 backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json create mode 100644 backend/migrations/20261002124918_job_token_scopes.down.sql create mode 100644 backend/migrations/20261002124918_job_token_scopes.up.sql create mode 100644 backend/tests/job_token_scopes.rs create mode 100644 backend/windmill-common/src/scopes.rs create mode 100644 frontend/src/lib/components/JobTokenScopesSetting.svelte diff --git a/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json b/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json new file mode 100644 index 0000000000..5c6ee3d506 --- /dev/null +++ b/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, job_token_scopes, lock_error_logs, created_at)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, job_token_scopes, $6::text, clock_timestamp()\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text", + "Text", + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160" +} diff --git a/backend/.sqlx/query-095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json b/backend/.sqlx/query-095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json new file mode 100644 index 0000000000..852ce508ff --- /dev/null +++ b/backend/.sqlx/query-095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec.json @@ -0,0 +1,152 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels -- 44\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27,\n -- $44 (payload labels) merged with the labels of the runnable's folder, if any\n -- ($45/$46 are runnable_path/workspace_id again, kept separate to avoid parameter type conflicts)\n (SELECT CASE WHEN fl.labels IS NULL THEN $44\n ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl)\n END\n FROM folder_labels($46, $45) AS fl(labels)))\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email, job_token_scopes)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41, $47)\n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email, job_token_scopes = EXCLUDED.job_token_scopes\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Text", + "Jsonb", + "Varchar", + "Uuid", + "Varchar", + "Varchar", + "Int8", + "Varchar", + "Jsonb", + { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + }, + "Varchar", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + }, + "Bool", + "Text", + "Varchar", + "Bool", + "Uuid", + "Int4", + "Int4", + "Int4", + "Varchar", + "Int4", + "Int2", + "Bool", + "Bool", + "Timestamptz", + "Varchar", + "Int2", + "Varchar", + "Varchar", + "Bool", + "Bool", + "JsonbArray", + "TextArray", + "Uuid", + { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + }, + "Bool", + "Varchar", + "Bool", + "Int8", + "TextArray", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec" +} diff --git a/backend/.sqlx/query-10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87.json b/backend/.sqlx/query-10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87.json new file mode 100644 index 0000000000..027f01d181 --- /dev/null +++ b/backend/.sqlx/query-10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1 FROM v2_job o, v2_job t,\n LATERAL (SELECT ARRAY_REMOVE(ARRAY[o.id, o.parent_job, o.root_job,\n o.flow_innermost_root_job], NULL) AS run) l\n WHERE o.id = $1 AND t.id = $2 AND t.workspace_id = o.workspace_id\n AND (t.id = ANY(l.run) OR t.parent_job = ANY(l.run)\n OR t.root_job = ANY(l.run) OR t.flow_innermost_root_job = ANY(l.run))\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87" +} diff --git a/backend/.sqlx/query-13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c.json b/backend/.sqlx/query-13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c.json new file mode 100644 index 0000000000..25aedfe3be --- /dev/null +++ b/backend/.sqlx/query-13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes FROM job_perms WHERE job_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c" +} diff --git a/backend/.sqlx/query-333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d.json b/backend/.sqlx/query-333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d.json new file mode 100644 index 0000000000..23b46f5577 --- /dev/null +++ b/backend/.sqlx/query-333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d.json @@ -0,0 +1,324 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email,\n job_perms.job_token_scopes\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + true + ] + }, + "hash": "333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d" +} diff --git a/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json b/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json new file mode 100644 index 0000000000..a5bde4d661 --- /dev/null +++ b/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n flow_version.value->>'failure_module' IS NOT NULL as has_failure_module,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of,\n flow.edited_by,\n flow.labels,\n flow.job_token_scopes\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "early_return", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "has_preprocessor", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "has_failure_module", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "chat_input_enabled", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "dedicated_worker", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "on_behalf_of", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 10, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + null, + null, + null, + null, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107" +} diff --git a/backend/.sqlx/query-4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82.json b/backend/.sqlx/query-4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82.json new file mode 100644 index 0000000000..7051c33008 --- /dev/null +++ b/backend/.sqlx/query-4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, job_token_scopes)\n SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8, $9", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "Varchar", + "Varchar", + "Bool", + "Bool", + "JsonbArray", + "TextArray", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82" +} diff --git a/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json b/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json new file mode 100644 index 0000000000..e1f4609346 --- /dev/null +++ b/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes\n FROM flow\n WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64" +} diff --git a/backend/.sqlx/query-5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544.json b/backend/.sqlx/query-5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544.json new file mode 100644 index 0000000000..56f5ddbc84 --- /dev/null +++ b/backend/.sqlx/query-5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544.json @@ -0,0 +1,253 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,\n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle,\n COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\",\n p.job_token_scopes AS \"job_token_scopes?\"\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n LEFT JOIN job_perms p ON p.job_id = j.id\n WHERE j.id = $1 AND j.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "kind!: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 12, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 15, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 17, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 18, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 20, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 21, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 22, + "name": "build_binary_only!", + "type_info": "Bool" + }, + { + "ordinal": 23, + "name": "job_token_scopes?", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + true, + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + true, + true, + true, + null, + true + ] + }, + "hash": "5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544" +} diff --git a/backend/.sqlx/query-60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe.json b/backend/.sqlx/query-60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe.json new file mode 100644 index 0000000000..f04f994e6a --- /dev/null +++ b/backend/.sqlx/query-60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe" +} diff --git a/backend/.sqlx/query-6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json b/backend/.sqlx/query-6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json new file mode 100644 index 0000000000..abcdf7baab --- /dev/null +++ b/backend/.sqlx/query-6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16.json @@ -0,0 +1,331 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n v2_job_queue.scheduled_for,\n v2_job.runnable_path,\n v2_job.kind as \"kind: JobKind\",\n v2_job.runnable_id as \"runnable_id: ScriptHash\",\n v2_job_queue.canceled_reason,\n v2_job_queue.canceled_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job_status.flow_status as \"flow_status: sqlx::types::Json>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email,\n job_perms.job_token_scopes", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id: ScriptHash", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "flow_status: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "Jsonb", + "TextArray", + "Jsonb", + "TextArray", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + true + ] + }, + "hash": "6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16" +} diff --git a/backend/.sqlx/query-67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a.json b/backend/.sqlx/query-67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a.json new file mode 100644 index 0000000000..cfd88ed5cb --- /dev/null +++ b/backend/.sqlx/query-67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) FROM v2_job\n WHERE id = $1 AND workspace_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a" +} diff --git a/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json b/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json new file mode 100644 index 0000000000..7c305d1e7a --- /dev/null +++ b/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of, on_behalf_of_email, assets, modules, job_token_scopes\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n -- Same three forms and the same prefix-first rule as permissioned_as_exists,\n -- superadmin fallback included: one acting outside their workspaces has no usr\n -- row but still authenticates.\n CASE WHEN on_behalf_of LIKE 'u/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar\n AND u.username = substring(on_behalf_of from 3)\n UNION ALL\n SELECT 1 FROM password p WHERE p.super_admin\n AND (p.username = substring(on_behalf_of from 3)\n OR p.email = substring(on_behalf_of from 3))))\n WHEN on_behalf_of LIKE 'g/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM group_ g WHERE g.workspace_id = $1::varchar\n AND g.name = substring(on_behalf_of from 3)))\n ELSE\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar\n AND u.username = on_behalf_of\n UNION ALL\n SELECT 1 FROM password p WHERE p.email = on_behalf_of\n AND p.super_admin))\n END, on_behalf_of_email, assets, modules, job_token_scopes\n FROM script\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea" +} diff --git a/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json b/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json new file mode 100644 index 0000000000..57822becd2 --- /dev/null +++ b/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\", flow.job_token_scopes FROM flow\n LEFT JOIN flow_version\n ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]\n LEFT JOIN flow_version_lite\n ON flow_version_lite.id = flow_version.id\n WHERE flow.path = $1 AND flow.workspace_id = $2 LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + true + ] + }, + "hash": "77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab" +} diff --git a/backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json b/backend/.sqlx/query-83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json similarity index 56% rename from backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json rename to backend/.sqlx/query-83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json index 96147d531e..b6a5d2a2d0 100644 --- a/backend/.sqlx/query-1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6.json +++ b/backend/.sqlx/query-83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT restart_unless_cancelled, timeout FROM script WHERE hash = $1 AND workspace_id = $2", + "query": "SELECT restart_unless_cancelled, timeout, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "timeout", "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "job_token_scopes", + "type_info": "TextArray" } ], "parameters": { @@ -21,9 +26,10 @@ ] }, "nullable": [ + true, true, true ] }, - "hash": "1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6" + "hash": "83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924" } diff --git a/backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json b/backend/.sqlx/query-aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc.json similarity index 76% rename from backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json rename to backend/.sqlx/query-aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc.json index 3383951c13..0bd5d0eca5 100644 --- a/backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json +++ b/backend/.sqlx/query-aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, j.args AS \"args: sqlx::types::Json>>\" FROM v2_job_queue q JOIN v2_job j USING (id) JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL AND s.restart_unless_cancelled", + "query": "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, j.args AS \"args: sqlx::types::Json>>\", p.job_token_scopes AS \"job_token_scopes?\" FROM v2_job_queue q JOIN v2_job j USING (id) JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id LEFT JOIN job_perms p ON p.job_id = q.id WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL AND s.restart_unless_cancelled", "describe": { "columns": [ { @@ -71,6 +71,11 @@ "ordinal": 7, "name": "args: sqlx::types::Json>>", "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "job_token_scopes?", + "type_info": "TextArray" } ], "parameters": { @@ -88,8 +93,9 @@ true, true, true, + true, true ] }, - "hash": "df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3" + "hash": "aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc" } diff --git a/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json b/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json new file mode 100644 index 0000000000..db729b97b1 --- /dev/null +++ b/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc" +} diff --git a/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json b/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json new file mode 100644 index 0000000000..47dcca381c --- /dev/null +++ b/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584" +} diff --git a/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json b/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json new file mode 100644 index 0000000000..b1f969f343 --- /dev/null +++ b/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json @@ -0,0 +1,102 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email, job_token_scopes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Varchar", + "Int8Array", + "Text", + "Text", + "Text", + "Varchar", + "Text", + "Bool", + "Jsonb", + "Text", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + }, + { + "Custom": { + "name": "script_kind", + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval", + "preprocessor" + ] + } + } + }, + "Varchar", + "VarcharArray", + "Int4", + "Int4", + "Int4", + "Bool", + "Bool", + "Int2", + "Bool", + "Bool", + "Int4", + "Int4", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Bool", + "Bool", + "Jsonb", + "Varchar", + "Int4", + "Bool", + "Int8", + "Jsonb", + "TextArray", + "Varchar", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4" +} diff --git a/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json b/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json new file mode 100644 index 0000000000..151296d652 --- /dev/null +++ b/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5" +} diff --git a/backend/.sqlx/query-c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e.json b/backend/.sqlx/query-c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e.json new file mode 100644 index 0000000000..393c88720d --- /dev/null +++ b/backend/.sqlx/query-c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email,\n job_token_scopes\n FROM job_perms WHERE job_id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "groups", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "folders", + "type_info": "JsonbArray" + }, + { + "ordinal": 6, + "name": "end_user_email", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e" +} diff --git a/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json b/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json new file mode 100644 index 0000000000..4ae34431dc --- /dev/null +++ b/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n ws_error_handler_muted = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now(),\n labels = COALESCE($13, labels),\n on_behalf_of = $14,\n on_behalf_of_email = $15,\n job_token_scopes = CASE WHEN $16 THEN $17 ELSE job_token_scopes END\n WHERE\n path = $11 AND workspace_id = $12", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Bool", + "Jsonb", + "Text", + "Varchar", + "Text", + "Text", + "TextArray", + "Varchar", + "Text", + "Bool", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1" +} diff --git a/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json b/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json new file mode 100644 index 0000000000..ae90b163ff --- /dev/null +++ b/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json @@ -0,0 +1,159 @@ +{ + "db_name": "PostgreSQL", + "query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels, job_token_scopes FROM script\n WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)\n ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "concurrency_key", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "debounce_key", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "debounce_delay_s", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "runnable_settings_handle", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "language: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 11, + "name": "dedicated_worker", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 13, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 14, + "name": "on_behalf_of", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd" +} diff --git a/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json b/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json new file mode 100644 index 0000000000..7f160dc856 --- /dev/null +++ b/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs,\n job_token_scopes\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[],\n -- Same predicate as clone_scripts.\n CASE WHEN on_behalf_of LIKE 'u/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar\n AND u.username = substring(on_behalf_of from 3)\n UNION ALL\n SELECT 1 FROM password p WHERE p.super_admin\n AND (p.username = substring(on_behalf_of from 3)\n OR p.email = substring(on_behalf_of from 3))))\n WHEN on_behalf_of LIKE 'g/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM group_ g WHERE g.workspace_id = $2::varchar\n AND g.name = substring(on_behalf_of from 3)))\n ELSE\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar\n AND u.username = on_behalf_of\n UNION ALL\n SELECT 1 FROM password p WHERE p.email = on_behalf_of\n AND p.super_admin))\n END, on_behalf_of_email, lock_error_logs, job_token_scopes\n FROM flow\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5" +} diff --git a/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json b/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json new file mode 100644 index 0000000000..909565b8c6 --- /dev/null +++ b/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes\n FROM flow WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8" +} diff --git a/backend/.sqlx/query-eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74.json b/backend/.sqlx/query-eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74.json new file mode 100644 index 0000000000..24c87414b1 --- /dev/null +++ b/backend/.sqlx/query-eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74.json @@ -0,0 +1,398 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n v2_job.id,\n v2_job.raw_code,\n v2_job.raw_lock,\n v2_job.raw_flow as \"raw_flow: _\",\n v2_job.tag,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job.kind as \"kind: _\",\n v2_job.runnable_id,\n v2_job.runnable_path,\n v2_job.parent_job,\n v2_job.root_job,\n v2_job.script_lang as \"script_lang: _\",\n v2_job.script_entrypoint_override,\n v2_job.flow_step,\n v2_job.flow_step_id,\n v2_job.flow_innermost_root_job,\n v2_job.\"trigger\",\n v2_job.trigger_kind as \"trigger_kind: _\",\n v2_job.same_worker,\n v2_job.visible_to_owner,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.cache_ttl,\n v2_job.timeout,\n v2_job.priority,\n v2_job.preprocessed,\n v2_job.args as \"args: _\",\n v2_job.labels,\n job_perms.job_token_scopes as \"job_token_scopes?\",\n v2_job.pre_run_error,\n\n v2_job_queue.started_at,\n v2_job_queue.scheduled_for,\n v2_job_queue.running,\n v2_job_queue.canceled_by,\n v2_job_queue.canceled_reason,\n v2_job_queue.suspend,\n v2_job_queue.suspend_until,\n v2_job_queue.worker,\n v2_job_queue.extras as \"extras: _\",\n\n v2_job_runtime.ping,\n v2_job_runtime.memory_peak,\n\n v2_job_status.flow_status as \"flow_status: _\",\n v2_job_status.flow_leaf_jobs as \"flow_leaf_jobs: _\",\n v2_job_status.workflow_as_code_status as \"workflow_as_code_status: _\",\n\n concurrency_key.key as \"concurrency_key?\"\n FROM v2_job_queue\n INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id\n LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id\n LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id\n WHERE v2_job_queue.workspace_id = $1\n AND v2_job_queue.running = false\n AND v2_job.parent_job IS NULL\n AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'\n ORDER BY v2_job.created_at DESC\n LIMIT $2\n OFFSET $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "raw_code", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "raw_lock", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "raw_flow: _", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: _", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 13, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 14, + "name": "script_lang: _", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 15, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "flow_step", + "type_info": "Int4" + }, + { + "ordinal": 17, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 19, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 20, + "name": "trigger_kind: _", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 21, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 22, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 23, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 24, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 26, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 28, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 29, + "name": "args: _", + "type_info": "Jsonb" + }, + { + "ordinal": 30, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 31, + "name": "job_token_scopes?", + "type_info": "TextArray" + }, + { + "ordinal": 32, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 33, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 34, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 35, + "name": "running", + "type_info": "Bool" + }, + { + "ordinal": 36, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 37, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 38, + "name": "suspend", + "type_info": "Int4" + }, + { + "ordinal": 39, + "name": "suspend_until", + "type_info": "Timestamptz" + }, + { + "ordinal": 40, + "name": "worker", + "type_info": "Varchar" + }, + { + "ordinal": 41, + "name": "extras: _", + "type_info": "Jsonb" + }, + { + "ordinal": 42, + "name": "ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 43, + "name": "memory_peak", + "type_info": "Int4" + }, + { + "ordinal": 44, + "name": "flow_status: _", + "type_info": "Jsonb" + }, + { + "ordinal": 45, + "name": "flow_leaf_jobs: _", + "type_info": "Jsonb" + }, + { + "ordinal": 46, + "name": "workflow_as_code_status: _", + "type_info": "Jsonb" + }, + { + "ordinal": 47, + "name": "concurrency_key?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false + ] + }, + "hash": "eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74" +} diff --git a/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json b/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json new file mode 100644 index 0000000000..ad4991a7cf --- /dev/null +++ b/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels,\n on_behalf_of, on_behalf_of_email, job_token_scopes\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7,\n $8,\n $9, $10::text::json, $11, now(), $12,\n $13, $14, $15\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Bool", + "Jsonb", + "Text", + "Varchar", + "TextArray", + "Varchar", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 765d297205..3ef967569d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -fe47a306d3760ac2d5a8e14365f05a3503a3ac35 +259ad3bfeef5285ba80eedc86309b11dca001220 diff --git a/backend/migrations/20261002124918_job_token_scopes.down.sql b/backend/migrations/20261002124918_job_token_scopes.down.sql new file mode 100644 index 0000000000..be2c13b3ff --- /dev/null +++ b/backend/migrations/20261002124918_job_token_scopes.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE job_perms DROP COLUMN IF EXISTS job_token_scopes; +ALTER TABLE flow DROP COLUMN IF EXISTS job_token_scopes; +ALTER TABLE script DROP COLUMN IF EXISTS job_token_scopes; diff --git a/backend/migrations/20261002124918_job_token_scopes.up.sql b/backend/migrations/20261002124918_job_token_scopes.up.sql new file mode 100644 index 0000000000..6b24325378 --- /dev/null +++ b/backend/migrations/20261002124918_job_token_scopes.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE script ADD COLUMN IF NOT EXISTS job_token_scopes text[]; +ALTER TABLE flow ADD COLUMN IF NOT EXISTS job_token_scopes text[]; +ALTER TABLE job_perms ADD COLUMN IF NOT EXISTS job_token_scopes text[]; diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index eafbda45c5..77963b73c6 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -6167,6 +6167,21 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n continue; } if let Some(job) = job.unwrap() { + // Read while the job is still queued: a re-run (perpetual, retry) takes its cap from + // here once the `job_perms` row is swept after completion. A failed read leaves the + // job for the next sweep rather than completing it with no cap. + let perms = + match windmill_common::auth::get_job_perms(db, &job.id, &job.workspace_id).await { + Ok(perms) => perms, + Err(e) => { + tracing::error!( + "Could not read the permissions of zombie job {}: {e:#}", + job.id + ); + continue; + } + }; + let job_token_scopes = perms.as_ref().and_then(|p| p.job_token_scopes.clone()); let label = ephemeral_script_token_label(&job.permissioned_as, &job.created_by); let token = create_token_for_owner( &db, @@ -6176,7 +6191,7 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n job_token_expiry_secs(&db, &job.workspace_id).await, &job.permissioned_as_email, &job.id, - None, + perms, Some(format!("handle_zombie_jobs")), ) .await @@ -6198,10 +6213,12 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n ); let memory_peak = job.memory_peak.unwrap_or(0); let (_, killpill_rx_never_used) = KillpillSender::new(1); + let mut completed = windmill_queue::MiniCompletedJob::from(job); + completed.job_token_scopes = job_token_scopes; let _ = handle_job_error( db, &client, - &windmill_queue::MiniCompletedJob::from(job), + &completed, memory_peak, None, error::Error::ExecutionErr(error_message.clone()), diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 169eb81681..2a518a7cc8 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -97,7 +97,7 @@ draft: workspace_id(char), path(char), typ(draft_type), value(json), created_at( email_to_igroup: email(char), igroup(char) email_trigger: path(char), local_part(char), workspaced_local_part(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind) -flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[]) +flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[]), job_token_scopes(text[]) FK: (workspace_id) -> workspace(id) flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool) FK: (workspace_id) -> workspace(id) @@ -132,7 +132,7 @@ input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_t FK: (workspace_id) -> workspace(id) instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char) job_logs: job_id(uuid), workspace_id(char), created_at(ts), logs(text), log_offset(int), log_file_index(text[]) -job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char) +job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[]) job_resolution: job_id(uuid), workspace_id(char), resolved_at(ts), resolved_by(char), note(text), automatic(bool) job_result_stream: job_id(uuid), workspace_id(text), stream(text) job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int) @@ -183,7 +183,7 @@ resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), appro runnable_settings: hash(bigint), debouncing_settings(bigint), concurrency_settings(bigint) schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedule(char), enabled(bool), script_path(char), args(jsonb), extra_perms(jsonb), is_flow(bool), email(char), error(text), timezone(char), on_failure(char), on_recovery(char), on_failure_times(int), on_failure_exact(bool), on_failure_extra_args(jsonb), on_recovery_times(int), on_recovery_extra_args(jsonb), ws_error_handler_muted(bool), retry(jsonb), summary(char), no_flow_overlap(bool), tag(char), paused_until(ts), on_success(char), on_success_extra_args(jsonb), cron_version(text), description(text), dynamic_skip(char), labels(text[]) FK: (workspace_id) -> workspace(id) -script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text) +script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text), job_token_scopes(text[]) FK: (workspace_id) -> workspace(id) skip_workspace_diff_tally: workspace_id(char), added_at(ts) sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) diff --git a/backend/tests/asset_trigger_dispatch.rs b/backend/tests/asset_trigger_dispatch.rs index c165958a33..08d755f5d1 100644 --- a/backend/tests/asset_trigger_dispatch.rs +++ b/backend/tests/asset_trigger_dispatch.rs @@ -245,6 +245,7 @@ fn make_mini(id: Uuid, runnable_path: &str) -> MiniCompletedJob { cache_ignore_s3_path: None, runnable_settings_handle: None, build_binary_only: false, + job_token_scopes: None, } } @@ -330,6 +331,7 @@ async fn end_to_end_asset_dispatch(db: Pool) -> anyhow::Result<()> { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; let completed = RunJob::from(job).run_until_complete(&db, false, port).await; assert!( @@ -493,6 +495,7 @@ async fn partition_dynamic_resolved_persisted_and_propagated( concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; let completed = RunJob::from(job) .arg("tenant_id", json!("acme")) diff --git a/backend/tests/batch_rerun.rs b/backend/tests/batch_rerun.rs index 51a6afa565..58cf27db87 100644 --- a/backend/tests/batch_rerun.rs +++ b/backend/tests/batch_rerun.rs @@ -53,6 +53,7 @@ fn script_payload() -> JobPayload { concurrency_settings: ConcurrencySettings::default().into(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, } } @@ -63,6 +64,7 @@ fn flow_payload() -> JobPayload { apply_preprocessor: false, version: FLOW_VERSION, labels: None, + job_token_scopes: None, } } diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 14f8c422a4..50fad28a04 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -854,6 +854,7 @@ fn run_main_script_job(hash: i64) -> RunJob { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) } diff --git a/backend/tests/caller_modules_strip.rs b/backend/tests/caller_modules_strip.rs index e6f96c04ec..a65ca22d1b 100644 --- a/backend/tests/caller_modules_strip.rs +++ b/backend/tests/caller_modules_strip.rs @@ -54,6 +54,7 @@ async fn stored_modules(db: &Pool, payload: JobPayload) -> Option) { concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; assert_eq!(stored_modules(&db, deployed).await, None); diff --git a/backend/tests/dedicated_workers.rs b/backend/tests/dedicated_workers.rs index 13fb043454..5e9c371f90 100644 --- a/backend/tests/dedicated_workers.rs +++ b/backend/tests/dedicated_workers.rs @@ -85,6 +85,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000001, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -122,6 +123,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000002, + job_token_scopes: None, }) .arg("x", json!(7)) .push(&db) @@ -161,6 +163,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000003, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -205,6 +208,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(21)) .push(&db) @@ -247,6 +251,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -265,6 +270,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -327,6 +333,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000004, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await @@ -356,6 +363,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000005, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -391,6 +399,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000006, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -427,6 +436,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000007, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -465,6 +475,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000008, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -500,6 +511,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000009, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -511,6 +523,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000010, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -564,6 +577,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -622,6 +636,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(7)) .push(&db) @@ -634,6 +649,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000011, + job_token_scopes: None, }) .arg("x", json!(7)) .push(&db) @@ -683,6 +699,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000012, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -742,6 +759,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000013, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await @@ -777,6 +795,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -795,6 +814,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -848,6 +868,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -867,6 +888,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -926,6 +948,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000014, + job_token_scopes: None, }) .arg("x", json!(20)) .push(&db) @@ -938,6 +961,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000014, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -988,6 +1012,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1038,6 +1063,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1089,6 +1115,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1142,6 +1169,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1160,6 +1188,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1222,6 +1251,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(7)) .push(&db) @@ -1240,6 +1270,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .workspace("test-workspace-2") .arg("x", json!(7)) @@ -1294,6 +1325,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1338,6 +1370,7 @@ mod dedicated_worker_tests { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1375,6 +1408,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000015, + job_token_scopes: None, }) .push(&db) .await; @@ -1412,6 +1446,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000019, + job_token_scopes: None, }) .push(&db) .await; @@ -1451,6 +1486,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000016, + job_token_scopes: None, }) .arg("x", json!(1)) .push(&db) @@ -1490,6 +1526,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000017, + job_token_scopes: None, }) .arg("x", json!(5)) .push(&db) @@ -1529,6 +1566,7 @@ mod dedicated_worker_tests { apply_preprocessor: false, labels: None, version: 3000000000000018, + job_token_scopes: None, }) .push(&db) .await; diff --git a/backend/tests/dependency_job_tag.rs b/backend/tests/dependency_job_tag.rs index e1b5dc4628..58b320d2c1 100644 --- a/backend/tests/dependency_job_tag.rs +++ b/backend/tests/dependency_job_tag.rs @@ -115,6 +115,7 @@ async fn push_binary_prebuild(db: &Pool, tag: Option<&str>) -> anyhow: None, None, None, + None, ) .await?; tx.commit().await?; diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index 59895b7a3a..dad6c86aa3 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -452,6 +452,7 @@ def main(): ws_error_handler_muted: None, labels: None, skip_draft_deletion: None, + job_token_scopes: None, }) .send() .await diff --git a/backend/tests/error_handler.rs b/backend/tests/error_handler.rs index 5d5b7a828b..4f58685221 100644 --- a/backend/tests/error_handler.rs +++ b/backend/tests/error_handler.rs @@ -161,6 +161,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; @@ -285,6 +286,7 @@ async fn test_error_handler_muted_on_script(db: Pool) -> anyhow::Resul concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; @@ -381,6 +383,7 @@ async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyh concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index 7ea221c2e8..3806bb2886 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -50,6 +50,7 @@ fn flow_module(id: &str, value: FlowModuleValue) -> FlowModule { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, } } @@ -2974,6 +2975,7 @@ async fn test_flow_env_marks_sub_flows_only_without_ancestor_env( apply_preprocessor: false, version, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index 1a973f6fe6..0038effc78 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -57,6 +57,7 @@ mod job_payload { language: ScriptLang::Deno, priority: None, apply_preprocessor: false, + job_token_scopes: None, }) .arg("world", json!("foo")) .run_until_complete(&db, false, port) @@ -92,6 +93,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete_with(db, false, port, |id| async move { let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id) @@ -435,6 +437,7 @@ mod job_payload { apply_preprocessor: false, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await @@ -485,6 +488,7 @@ mod job_payload { apply_preprocessor: true, version: 1443253234253456, labels: None, + job_token_scopes: None, }) .run_until_complete_with(db, false, port, |id| async move { let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id) @@ -556,6 +560,7 @@ mod job_payload { apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await @@ -1150,6 +1155,7 @@ mod job_payload { apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(db, false, port) .await; @@ -1625,6 +1631,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -1676,6 +1683,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -1727,6 +1735,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -1778,6 +1787,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -1831,6 +1841,7 @@ mod job_payload { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) diff --git a/backend/tests/job_token_scopes.rs b/backend/tests/job_token_scopes.rs new file mode 100644 index 0000000000..8129fe2b3e --- /dev/null +++ b/backend/tests/job_token_scopes.rs @@ -0,0 +1,354 @@ +//! A job token restricted by `job_token_scopes` reaches only what its scopes allow, plus the +//! runtime routes about its own job, and no job it creates holds a wider token than it. + +use reqwest::StatusCode; +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::jobs::JobPayload; +use windmill_test_utils::*; + +const OIDC_JOB: &str = "b0000000-0000-0000-0000-000000000001"; +const RUN_JOB: &str = "b0000000-0000-0000-0000-000000000002"; +const FLOW_JOB: &str = "b0000000-0000-0000-0000-000000000003"; + +async fn insert_job( + db: &Pool, + id: &str, + parent: Option<&str>, + scopes: &[&str], +) -> anyhow::Result<()> { + let id = Uuid::parse_str(id)?; + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, parent_job) + VALUES ($1, 'test-workspace', 'test-user-3', 'u/test-user-3', 'test3@windmill.dev', + 'script', 'deno', 'u/test-user-3/agent', 'deno', $2)", + ) + .bind(id) + .bind(parent.map(Uuid::parse_str).transpose()?) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, + workspace_id, job_token_scopes) + VALUES ($1, 'test3@windmill.dev', 'test-user-3', false, false, '{}', '{}', + 'test-workspace', $2)", + ) + .bind(id) + .bind(scopes) + .execute(db) + .await?; + Ok(()) +} + +async fn job_token(db: &Pool, id: &str) -> anyhow::Result { + Ok(windmill_common::auth::create_token_for_owner( + db, + "test-workspace", + "u/test-user-3", + "ephemeral-script", + 300, + "test3@windmill.dev", + &Uuid::parse_str(id)?, + None, + None, + ) + .await?) +} + +async fn insert_script( + db: &Pool, + path: &str, + hash: i64, + scopes: Option<&[&str]>, +) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO script (workspace_id, created_by, content, schema, summary, description, + path, hash, language, lock, kind, job_token_scopes) + VALUES ('test-workspace', 'test-user-3', 'export function main() {}', '{}', '', '', + $1, $2, 'deno', '', 'script', $3)", + ) + .bind(path) + .bind(hash) + .bind(scopes) + .execute(db) + .await?; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_restricted_job_token_is_confined(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + insert_script(&db, "u/test-user-3/open", 515151, None).await?; + insert_script( + &db, + "u/test-user-3/oidc_only", + 525252, + Some(&["oidc:write"]), + ) + .await?; + insert_job(&db, FLOW_JOB, None, &["oidc:write"]).await?; + insert_job(&db, OIDC_JOB, Some(FLOW_JOB), &["oidc:write"]).await?; + insert_job(&db, RUN_JOB, None, &["jobs:run"]).await?; + + let server = ApiServer::start(db.clone()).await?; + set_jwt_secret().await; + let base = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + let client = reqwest::Client::new(); + let oidc_token = job_token(&db, OIDC_JOB).await?; + + let refused = [ + client.get(format!("{base}/variables/get_value/u/test-user-3/secret")), + client + .post(format!("{base}/scripts/create")) + .json(&json!({})), + client + .post(format!("{base}/schedules/create")) + .json(&json!({})), + client + .post(format!("{base}/jobs/run/p/u/test-user-3/open")) + .json(&json!({})), + ]; + for request in refused { + let resp = request.bearer_auth(&oidc_token).send().await?; + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "{}", + resp.text().await? + ); + } + let own = client + .get(format!("{base}/jobs_u/get_root_job_id/{OIDC_JOB}")) + .bearer_auth(&oidc_token) + .send() + .await?; + assert_eq!(own.status(), StatusCode::OK, "{}", own.text().await?); + // The orchestrator reads a flow's step results with the token of the step that just ran. + for (job, refused) in [(FLOW_JOB, false), (RUN_JOB, true)] { + let resp = client + .get(format!("{base}/jobs/result_by_id/{job}/a")) + .bearer_auth(&oidc_token) + .send() + .await?; + assert_eq!(resp.status() == StatusCode::FORBIDDEN, refused, "{job}"); + } + // Its progress reaches only the flow it runs in. + for (flow, refused) in [(FLOW_JOB, false), (RUN_JOB, true)] { + let resp = client + .post(format!("{base}/job_metrics/set_progress/{OIDC_JOB}")) + .bearer_auth(&oidc_token) + .json(&json!({ "percent": 50, "flow_job_id": flow })) + .send() + .await?; + assert_eq!(resp.status() == StatusCode::FORBIDDEN, refused, "{flow}"); + } + + // Imported queue rows bypass push, so a restricted token cannot import, even an admin's. + let admin_job = Uuid::parse_str("b0000000-0000-0000-0000-000000000004")?; + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'test@windmill.dev', + 'script', 'deno', 'u/test-user/agent', 'deno')", + ) + .bind(admin_job) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, + workspace_id, job_token_scopes) + VALUES ($1, 'test@windmill.dev', 'test-user', true, false, '{}', '{}', 'test-workspace', + '{jobs:run}')", + ) + .bind(admin_job) + .execute(&db) + .await?; + let admin_token = windmill_common::auth::create_token_for_owner( + &db, + "test-workspace", + "u/test-user", + "ephemeral-script", + 300, + "test@windmill.dev", + &admin_job, + None, + None, + ) + .await?; + let resp = client + .post(format!("{base}/jobs/queue/import")) + .bearer_auth(&admin_token) + .json(&json!([])) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{}", resp.text().await?); + // Nor can it, even an admin's, place a child in an unrelated run: the flow-run routes + // trust that lineage. + let resp = client + .post(format!( + "{base}/jobs/run/p/u/test-user-3/open?root_job={RUN_JOB}" + )) + .bearer_auth(&admin_token) + .json(&json!({})) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED, "{}", resp.text().await?); + let child = Uuid::parse_str(&resp.text().await?)?; + let claimed: bool = sqlx::query_scalar( + "SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) IS TRUE FROM v2_job + WHERE id = $1", + ) + .bind(child) + .bind(Uuid::parse_str(RUN_JOB)?) + .fetch_one(&db) + .await?; + assert!(!claimed); + + // A job it starts is capped at its own scopes, intersected with the target's setting. + let run_token = job_token(&db, RUN_JOB).await?; + for (path, expected) in [ + ("open", vec!["jobs:run".to_string()]), + ("oidc_only", vec![]), + ] { + let resp = client + .post(format!("{base}/jobs/run/p/u/test-user-3/{path}")) + .bearer_auth(&run_token) + .json(&json!({})) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + let child = Uuid::parse_str(&resp.text().await?)?; + let scopes: Option> = + sqlx::query_scalar("SELECT job_token_scopes FROM job_perms WHERE job_id = $1") + .bind(child) + .fetch_one(&db) + .await?; + assert_eq!(scopes, Some(expected), "child of {path}"); + } + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_flow_steps_inherit_the_flow_restriction(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let value = json!({ "modules": [ + { "id": "a", "value": { "type": "identity" }, + "job_token_scopes": ["oidc:write", "variables:read"] }, + { "id": "b", "value": { "type": "identity" } }, + ] }); + sqlx::query( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, + schema, extra_perms, versions) + VALUES ('test-workspace', 'u/test-user/agent_flow', '', '', $1, 'test-user', now(), '{}', + '{}', '{7171}')", + ) + .bind(&value) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by) + VALUES (7171, 'test-workspace', 'u/test-user/agent_flow', $1, '{}', 'test-user')", + ) + .bind(&value) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let scopes = |v: &[&str]| Some(v.iter().map(|s| s.to_string()).collect::>()); + // A step's own setting narrows the flow's restriction and never widens it; in an + // unrestricted flow it restricts that step alone. + for (flow_scopes, expected_a, expected_b) in [ + (scopes(&["oidc:write"]), scopes(&["oidc:write"]), scopes(&["oidc:write"])), + (None, scopes(&["oidc:write", "variables:read"]), None), + ] { + let flow = RunJob::from(JobPayload::Flow { + path: "u/test-user/agent_flow".to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version: 7171, + labels: None, + job_token_scopes: flow_scopes.clone(), + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + + let steps: Vec<(String, Option>)> = sqlx::query_as( + "SELECT j.flow_step_id, p.job_token_scopes FROM v2_job j + JOIN job_perms p ON p.job_id = j.id + WHERE j.parent_job = $1 ORDER BY j.flow_step_id", + ) + .bind(flow.id) + .fetch_all(&db) + .await?; + assert_eq!( + steps, + vec![("a".to_string(), expected_a), ("b".to_string(), expected_b)], + "flow scopes {flow_scopes:?}" + ); + } + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_deploy_without_the_field_keeps_the_restriction( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api/w/test-workspace", server.addr.port()); + let client = reqwest::Client::new(); + let script = |path: &str, scopes: Option| { + let mut body = json!({ + "path": path, "summary": "", "description": "", "content": "echo 42", "language": "bash", + }); + if let Some(scopes) = scopes { + body["job_token_scopes"] = scopes; + } + body + }; + let stored = |path: &'static str| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, Option>>( + "SELECT job_token_scopes FROM script WHERE path = $1 ORDER BY created_at DESC LIMIT 1", + ) + .bind(path) + .fetch_one(&db) + .await + } + }; + + let resp = client + .post(format!("{base}/scripts/create")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/agent", Some(json!(["oidc:write"])))) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED, "{}", resp.text().await?); + + // A rename by a client that does not know the field carries the restriction along. + let resp = client + .post(format!("{base}/scripts/update/u/test-user/agent")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/renamed", None)) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + assert_eq!(stored("u/test-user/renamed").await?, Some(vec!["oidc:write".to_string()])); + + // An explicit null clears it. + let resp = client + .post(format!("{base}/scripts/update/u/test-user/renamed")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/renamed", Some(serde_json::Value::Null))) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + assert_eq!(stored("u/test-user/renamed").await?, None); + Ok(()) +} diff --git a/backend/tests/list_jobs.rs b/backend/tests/list_jobs.rs index 29a49c1610..d33618d048 100644 --- a/backend/tests/list_jobs.rs +++ b/backend/tests/list_jobs.rs @@ -957,6 +957,7 @@ async fn test_job_labels_propagated_at_push_time(db: Pool) -> anyhow:: .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: Some(vec!["prod".to_string(), "deploy".to_string()]), + job_token_scopes: None, }) .push(&db) .await; @@ -1060,6 +1061,7 @@ async fn test_job_label_filter(db: Pool) -> anyhow::Result<()> { .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: Some(vec!["prod".to_string()]), + job_token_scopes: None, }) .push(&db) .await; @@ -1077,6 +1079,7 @@ async fn test_job_label_filter(db: Pool) -> anyhow::Result<()> { .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: Some(vec!["staging".to_string()]), + job_token_scopes: None, }) .push(&db) .await; diff --git a/backend/tests/nativets_stress.rs b/backend/tests/nativets_stress.rs index 21d3911778..d1e371ef81 100644 --- a/backend/tests/nativets_stress.rs +++ b/backend/tests/nativets_stress.rs @@ -191,6 +191,7 @@ async fn push_job(db: &Pool, content: &str, args: &serde_json::Value) None, None, None, + None, ) .await .expect("push must succeed"); diff --git a/backend/tests/preview_native_tag.rs b/backend/tests/preview_native_tag.rs index eba686396a..39f985cefe 100644 --- a/backend/tests/preview_native_tag.rs +++ b/backend/tests/preview_native_tag.rs @@ -76,6 +76,7 @@ async fn push_preview_and_get_row( None, None, None, + None, ) .await .expect("push must succeed"); diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 9616df267a..33961e465f 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1278,6 +1278,7 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool) -> anyhow::Res debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("who", json!("alice")) .arg("count", json!(7)) diff --git a/backend/tests/restart_at_step_api.rs b/backend/tests/restart_at_step_api.rs index c5ab703f46..2b57de2c64 100644 --- a/backend/tests/restart_at_step_api.rs +++ b/backend/tests/restart_at_step_api.rs @@ -41,6 +41,7 @@ async fn test_api_restart_at_step_nested_happy(db: Pool) -> anyhow::Re apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; @@ -85,6 +86,7 @@ async fn test_api_restart_at_step_top_level_happy(db: Pool) -> anyhow: apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; @@ -118,6 +120,7 @@ async fn test_api_restart_at_step_rejects_unknown_step(db: Pool) -> an apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; @@ -161,6 +164,7 @@ async fn test_api_restart_at_step_rejects_out_of_range_iteration( apply_preprocessor: true, version: 1443253234253454, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; diff --git a/backend/tests/restart_flow_version_scope.rs b/backend/tests/restart_flow_version_scope.rs index 82e8f8e4e7..551e8c3619 100644 --- a/backend/tests/restart_flow_version_scope.rs +++ b/backend/tests/restart_flow_version_scope.rs @@ -53,6 +53,7 @@ async fn push_restart( None, None, None, + None, ) .await?; tx.commit().await?; diff --git a/backend/tests/success_handler.rs b/backend/tests/success_handler.rs index fe6127c27e..8fc4182d9e 100644 --- a/backend/tests/success_handler.rs +++ b/backend/tests/success_handler.rs @@ -181,6 +181,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index dec5abfb23..938763f21e 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -212,6 +212,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "b".to_string(), @@ -260,6 +261,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }], modules_node: None, } @@ -282,6 +284,7 @@ async fn test_deno_flow(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, ], same_worker: false, @@ -398,6 +401,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "b".to_string(), @@ -455,6 +459,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "e".to_string(), @@ -498,6 +503,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, ], modules_node: None, @@ -520,6 +526,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "c".to_string(), @@ -569,6 +576,7 @@ async fn test_deno_flow_same_worker(db: Pool) -> anyhow::Result<()> { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, ], same_worker: true, @@ -5791,6 +5799,7 @@ async fn test_flow_tag_judged_as_written_before_preprocessor( apply_preprocessor: true, version: 1443253234253456, labels: None, + job_token_scopes: None, }) .as_user("test-user-2", "test2@windmill.dev") .run_until_complete(&db, false, port) diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 869f40e14c..ced8385ef5 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -104,6 +104,32 @@ pub struct ExpiringAuthCache { pub job_id: Option, } +/// Whether `target` belongs to the flow run of the job `job_id`: the job itself, one of its +/// ancestors, or a job whose parent or root is one of them (a sibling step, a loop or branch +/// iteration, any job pushed as a child of one of them). A lookup that fails reads as no. +async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool { + if job_id == target { + return true; + } + sqlx::query_scalar!( + "SELECT EXISTS ( + SELECT 1 FROM v2_job o, v2_job t, + LATERAL (SELECT ARRAY_REMOVE(ARRAY[o.id, o.parent_job, o.root_job, + o.flow_innermost_root_job], NULL) AS run) l + WHERE o.id = $1 AND t.id = $2 AND t.workspace_id = o.workspace_id + AND (t.id = ANY(l.run) OR t.parent_job = ANY(l.run) + OR t.root_job = ANY(l.run) OR t.flow_innermost_root_job = ANY(l.run)) + )", + job_id, + target + ) + .fetch_one(db) + .await + .ok() + .flatten() + .unwrap_or(false) +} + pub struct AuthCache { db: DB, superadmin_secret: Option, @@ -1131,8 +1157,20 @@ pub async fn resolve_opt_job_authed( return Err((err, parts)); } } + let own_job_runtime_route = match opt_job_authed.job_id { + Some(job_id) if opt_job_authed.authed.scopes.is_some() => { + crate::scopes::is_own_job_runtime_route(path, method, job_id) + || match crate::scopes::flow_run_read_route_job(path, method) { + Some(target) => { + job_in_same_flow_run(&cache.db, job_id, target).await + } + None => false, + } + } + _ => false, + }; let authed = &mut opt_job_authed.authed; - if authed.scopes.is_some() { + if authed.scopes.is_some() && !own_job_runtime_route { transform_old_scope_to_new_scope(authed.scopes.as_mut()); if let Err(err) = crate::scopes::check_scopes_for_route( diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 5b6a618e5f..30c3bb65af 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -35,6 +35,7 @@ use windmill_common::{ }; use scopes::ScopeDefinition; +use windmill_common::scopes::scope_contains; // Re-export key auth types and functions pub use auth::{ @@ -475,11 +476,11 @@ pub fn is_effectively_unscoped(scopes: Option<&[String]>) -> bool { /// refused. An admin check is not a substitute — it answers for the user behind the /// token, not for the token's own scopes. /// -/// This bounds the token making the request, not every route to the key. A job token -/// is minted unscoped from its owner's privileges, so a `jobs:run` token still reaches -/// the key indirectly by running a job as a workspace admin — the same property that -/// lets git-sync export it. Confining that means not inheriting unscoped privilege -/// into job tokens, which is a far wider change than this guard. +/// This bounds the token making the request, not every route to the key. The jobs a +/// scoped user token runs get a token with their owner's privileges, capped only by the +/// runnable's own `job_token_scopes` (see [`caller_scope_ceiling`]), so a `jobs:run` +/// token still reaches the key indirectly by running an unrestricted job as a workspace +/// admin — the same property that lets git-sync export it. pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<()> { if is_effectively_unscoped(authed.scopes.as_deref()) { return Ok(()); @@ -492,6 +493,50 @@ pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<() )) } +/// For a route that authenticates its token itself rather than through the route layer +/// (which checks scopes): a job token restricted by `job_token_scopes` must still hold +/// `required` there. Other credentials keep the access those routes always gave them. +pub fn check_job_token_scope(authed: &ApiAuthed, required: F) -> error::Result<()> +where + F: FnOnce() -> String, +{ + if authed.job_id.is_some() { + check_scopes(authed, required) + } else { + Ok(()) + } +} + +/// The cap on the token of a job pushed on `authed`'s request, to pass to `push` as its +/// scope ceiling. A job token caps every job it starts at its own job's scopes, so a +/// restricted job cannot widen its reach by running another one; the job row is the +/// source rather than the token's claims, which the MCP proxy narrows per route. Any +/// other credential caps nothing: the jobs it runs act as their owner, as they always did. +pub async fn caller_scope_ceiling( + db: &windmill_common::DB, + authed: &ApiAuthed, +) -> error::Result>> { + let Some(job_id) = authed.job_id else { + return Ok(None); + }; + // A job token is minted with scopes exactly when its job is restricted, so an unscoped one + // has nothing to read. + if authed.scopes.is_none() { + return Ok(None); + } + let row = sqlx::query_scalar!( + "SELECT job_token_scopes FROM job_perms WHERE job_id = $1", + job_id + ) + .fetch_optional(db) + .await?; + Ok(match row { + Some(scopes) => scopes, + // The job left the queue and its row was swept: only the token's own claims are left. + None => authed.scopes.clone(), + }) +} + /// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes /// a credential on behalf of `authed`: the resulting credential must never be /// more privileged than the caller's own token. @@ -604,84 +649,6 @@ fn first_filter_tags(scopes: Option<&[String]>) -> Option> { }) } -/// Whether `caller` grants at least everything `requested` grants (directional -/// containment). -/// -/// This is intentionally NOT `ScopeDefinition::includes`: that method answers -/// "does this scope grant access to a required action" using OR semantics over -/// resources (any overlap counts, and a `*` on either side matches), which is -/// correct for access checks but unsafe for subset checks — it would let a -/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or -/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that -/// EVERY requested resource is covered by SOME caller resource. -fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { - if caller.domain != requested.domain { - return false; - } - - // write subsumes read; otherwise the action must match exactly. - match (caller.action.as_str(), requested.action.as_str()) { - (c, r) if c == r || (c == "write" && r == "read") => {} - // Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an - // app-editor token can mint the narrower run-only credential. - ("write", "run") if caller.domain == "apps" => {} - ("write", "cancel") if caller.domain == "jobs" => {} - _ => return false, - } - - if caller.domain == "jobs" && caller.action == "run" { - match (&caller.kind, &requested.kind) { - (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { - return false - } - // Caller pinned to a kind, but the request covers any kind. - (Some(_), None) => return false, - _ => {} - } - } - - match (&caller.resource, &requested.resource) { - // Caller is unrestricted on resources: covers everything. - (None, _) => true, - // Caller is resource-restricted but the request is not: broader, unless the - // caller lists `*` and so already spans every path. Kept in step with - // `ScopeDefinition::includes`, which accepts that same grant for a - // whole-collection read: what a token may exercise, it may also delegate. - (Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"), - (Some(caller_resources), Some(requested_resources)) => { - resource_set_contains(caller_resources, requested_resources) - } - } -} - -/// Every resource in `requested` must be covered by some resource in `caller`. -fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { - if caller.iter().any(|r| r == "*") { - return true; - } - requested - .iter() - .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) -} - -/// Directional: does the single caller resource pattern cover `requested`? -/// `caller` may be an exact path or a `/*` subtree wildcard; `requested` -/// may itself be a subtree wildcard, in which case the whole requested subtree -/// must fall within the caller's subtree. -fn resource_covers(caller: &str, requested: &str) -> bool { - if caller == requested { - return true; - } - let Some(prefix) = caller.strip_suffix("/*") else { - // An exact caller resource only covers itself (handled above). - return false; - }; - let requested_base = requested.strip_suffix("/*").unwrap_or(requested); - requested_base == prefix - || (requested_base.starts_with(prefix) - && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) -} - /// Returns a predicate that checks whether `path` is within the token's /// scope for `{domain}:{action}:{path}`. For tokens without scope /// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes), diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index f2a6612e42..4fa85dfa3a 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -6,469 +6,9 @@ * LICENSE-AGPL for a copy of the license. */ -use itertools::Itertools; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; use windmill_common::error::{Error, Result}; -/// Comprehensive scope system for JWT token authorization -/// -/// Scopes follow the format: {domain}:{action}[:{resource}] -/// Examples: -/// - "jobs:read" - Read access to jobs -/// - "scripts:write:f/folder/*" - Write access to scripts in a folder -/// - "*" - Full access (superuser) - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScopeDefinition { - pub domain: String, - pub action: String, - pub kind: Option, // For jobs:run:kind (optional) - pub resource: Option>, -} - -impl ScopeDefinition { - pub fn new( - domain: &str, - action: &str, - kind: Option<&str>, - resource: Option>, - ) -> Self { - Self { - domain: domain.to_string(), - action: action.to_string(), - kind: kind.map(|s| s.to_string()), - resource: resource, - } - } - - pub fn from_scope_string(scope: &str) -> Result { - let parts: Vec<&str> = scope.split(':').collect(); - - let into_owned_vec = |resources: &str| -> Vec { - let resources = resources - .split(",") - .collect_vec() - .into_iter() - .map(ToOwned::to_owned) - .collect_vec(); - - resources - }; - - match parts.len() { - 2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action - 3 => { - if parts[0] == "jobs" && parts[1] == "run" { - Ok(Self::new(parts[0], parts[1], Some(parts[2]), None)) - } else { - Ok(Self::new( - parts[0], - parts[1], - None, - Some(into_owned_vec(parts[2])), - )) - } - } - 4 => { - if parts[0] == "jobs" && parts[1] == "run" { - Ok(Self::new( - parts[0], - parts[1], - Some(parts[2]), - Some(into_owned_vec(parts[3])), - )) - } else { - Err(Error::BadRequest(format!( - "Invalid 4-part scope: {}", - scope - ))) - } - } - _ => Err(Error::BadRequest(format!( - "Invalid scope format: {}", - scope - ))), - } - } - - pub fn as_string(&self) -> String { - match (&self.kind, &self.resource) { - (Some(kind), Some(resource)) => { - format!( - "{}:{}:{}:{}", - self.domain, - self.action, - kind, - resource.join(",") - ) - } - (Some(kind), None) => { - format!("{}:{}:{}", self.domain, self.action, kind) - } - (None, Some(resource)) => { - format!("{}:{}:{}", self.domain, self.action, resource.join(",")) - } - (None, None) => format!("{}:{}", self.domain, self.action), - } - } - - pub fn includes(&self, other: &ScopeDefinition) -> bool { - if self.domain != other.domain { - return false; - } - - match (self.action.as_str(), other.action.as_str()) { - (a, b) if (a == "write" && b == "read") || (a == b) => {} - // Apps only: `write` can rewrite the app and its policy, so it also covers - // running its components. Not general — `jobs:write` must not grant - // `jobs:run`. The resource check below still confines it to the same app. - ("write", "run") if self.domain == "apps" => {} - ("write", "cancel") if self.domain == "jobs" => {} - _ => return false, - } - - if self.domain == "jobs" && self.action == "run" { - match (&self.kind, &other.kind) { - (Some(self_kind), Some(other_kind)) => { - if self_kind != other_kind { - return false; - } - } - (Some(_), None) => { - return false; - } - (None, _) => { - return true; - } - } - } - - match (&self.resource, &other.resource) { - (Some(self_resources), Some(other_resources)) => { - resources_match(self_resources, other_resources) - } - // A requirement naming no path is the whole domain, so only a grant that - // itself spans every path satisfies it. `*` is that grant — the scope UI - // accepts it as a resource path and `resources_match` already reads it as - // everything — while any listed path leaves the collection unauthorized. - (Some(self_resources), None) => self_resources.iter().any(|r| r == "*"), - (None, _) => true, - } - } -} - -fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool { - if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) { - return true; - } - - if scope_resources.len() <= 4 && accepted_resources.len() <= 4 { - return resources_match_small(scope_resources, accepted_resources); - } - - resources_match_large(scope_resources, accepted_resources) -} - -fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool { - for required in accepted_resources { - for scope_resource in scope_resources { - if resource_matches_pattern(scope_resource, required) { - return true; - } - } - } - false -} - -fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool { - let mut exact_matches = HashSet::new(); - let mut patterns = Vec::new(); - - for scope_resource in scope_resources { - if scope_resource.contains('*') { - patterns.push(scope_resource); - } else { - exact_matches.insert(scope_resource); - } - } - - for accepted_resource in accepted_resources { - if exact_matches.contains(accepted_resource) { - return true; - } - - for pattern in &patterns { - if resource_matches_pattern(pattern, accepted_resource) { - return true; - } - } - } - - false -} - -fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool { - if scope_resource == accepted_resource { - return true; - } - - let matches_wildcard = |pattern: &str, resource: &str| -> bool { - if !pattern.ends_with("/*") { - return false; - } - - let prefix = &pattern[..pattern.len() - 2]; - - if !resource.starts_with(prefix) { - return false; - } - - // If the resource is exactly the prefix, it matches - if resource.len() == prefix.len() { - return true; - } - - // If the resource is longer, the next character must be '/' for a valid match - // This prevents "u/user" from matching "u/use/*" - resource.chars().nth(prefix.len()) == Some('/') - }; - - // Check if either resource is a wildcard pattern and matches the other - matches_wildcard(scope_resource, accepted_resource) - || matches_wildcard(accepted_resource, scope_resource) -} - -// ───────────────────────────────────────────────────────────────── -// Route-level scope checking -// ───────────────────────────────────────────────────────────────── - -/// Available scope domains (top-level API categories) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ScopeDomain { - // Core resource domains - Jobs, - Scripts, - /// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a - /// `data_metrics:read` token must reach only this route, never the broader - /// `/scripts` routes (some of which do no further scope check). - DataMetrics, - Flows, - FlowConversations, - Apps, - Variables, - Resources, - Schedules, - Folders, - Users, - Groups, - Workspaces, - - // Trigger domains - HttpTriggers, - WebsocketTriggers, - KafkaTriggers, - NatsTriggers, - MqttTriggers, - AmqpTriggers, - SqsTriggers, - GcpTriggers, - AzureTriggers, - PostgresTriggers, - EmailTriggers, - - // Native trigger domains - NativeTriggers, - TriggersHistory, - - // System domains - Audit, - Settings, - Workers, - ServiceLogs, - Configs, - OAuth, - AI, - AiEvals, // AI agent eval datasets - - Indexer, - Teams, // Microsoft Teams integration - GitSync, // Git synchronization - - // Special domains - Capture, // Webhook capture - Drafts, // Draft resources - Favorites, // User favorites - Inputs, // Input templates - JobHelpers, // Job helper functions - ConcurrencyGroups, // Concurrency groups - Oidc, // OpenID Connect - Openapi, // OpenAPI generation - - // Additional domains - Acls, // Granular access control lists - RawApps, // Raw application data - AgentWorkers, // Agent workers management - Mcp, // MCP - Docs, // Self-hosted documentation search (read-only) -} - -impl ScopeDomain { - pub fn as_str(&self) -> &'static str { - match self { - Self::Jobs => "jobs", - Self::Scripts => "scripts", - Self::DataMetrics => "data_metrics", - Self::Flows => "flows", - Self::FlowConversations => "flow_conversations", - Self::Apps => "apps", - Self::Variables => "variables", - Self::Resources => "resources", - Self::Schedules => "schedules", - Self::Folders => "folders", - Self::Users => "users", - Self::Groups => "groups", - Self::Workspaces => "workspaces", - Self::HttpTriggers => "http_triggers", - Self::WebsocketTriggers => "websocket_triggers", - Self::KafkaTriggers => "kafka_triggers", - Self::NatsTriggers => "nats_triggers", - Self::MqttTriggers => "mqtt_triggers", - Self::AmqpTriggers => "amqp_triggers", - Self::SqsTriggers => "sqs_triggers", - Self::GcpTriggers => "gcp_triggers", - Self::AzureTriggers => "azure_triggers", - Self::PostgresTriggers => "postgres_triggers", - Self::EmailTriggers => "email_triggers", - Self::NativeTriggers => "native_triggers", - Self::TriggersHistory => "triggers_history", - Self::Audit => "audit", - Self::Settings => "settings", - Self::Workers => "workers", - Self::ServiceLogs => "service_logs", - Self::Configs => "configs", - Self::OAuth => "oauth", - Self::AI => "ai", - Self::AiEvals => "ai_evals", - Self::Capture => "capture", - Self::Drafts => "drafts", - Self::Favorites => "favorites", - Self::Inputs => "inputs", - Self::JobHelpers => "job_helpers", - Self::ConcurrencyGroups => "concurrency_groups", - Self::Oidc => "oidc", - Self::Openapi => "openapi", - Self::Acls => "acls", - Self::RawApps => "raw_apps", - Self::AgentWorkers => "agent_workers", - Self::Indexer => "indexer", - Self::Teams => "teams", - Self::GitSync => "git_sync", - Self::Mcp => "mcp", - Self::Docs => "docs", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "jobs" | "jobs_u" => Some(Self::Jobs), - "scripts" => Some(Self::Scripts), - // A distinct domain, not an alias of `scripts` (see the enum variant): - // a `data_metrics:read` token must not reach the broader /scripts routes. - "data_metrics" => Some(Self::DataMetrics), - "flows" => Some(Self::Flows), - "flow_conversations" => Some(Self::FlowConversations), - "apps" | "apps_u" => Some(Self::Apps), - "variables" => Some(Self::Variables), - "resources" => Some(Self::Resources), - "schedules" => Some(Self::Schedules), - "folders" => Some(Self::Folders), - "users" => Some(Self::Users), - "groups" => Some(Self::Groups), - "workspaces" => Some(Self::Workspaces), - "http_triggers" => Some(Self::HttpTriggers), - "websocket_triggers" => Some(Self::WebsocketTriggers), - "kafka_triggers" => Some(Self::KafkaTriggers), - "nats_triggers" => Some(Self::NatsTriggers), - "mqtt_triggers" => Some(Self::MqttTriggers), - "amqp_triggers" => Some(Self::AmqpTriggers), - "sqs_triggers" => Some(Self::SqsTriggers), - "gcp_triggers" => Some(Self::GcpTriggers), - "azure_triggers" => Some(Self::AzureTriggers), - "postgres_triggers" => Some(Self::PostgresTriggers), - "email_triggers" => Some(Self::EmailTriggers), - "audit" => Some(Self::Audit), - "settings" => Some(Self::Settings), - "workers" => Some(Self::Workers), - "service_logs" => Some(Self::ServiceLogs), - "configs" => Some(Self::Configs), - "oauth" => Some(Self::OAuth), - "ai" => Some(Self::AI), - "ai_evals" => Some(Self::AiEvals), - "indexer" | "srch" => Some(Self::Indexer), - "teams" => Some(Self::Teams), - "native_triggers" => Some(Self::NativeTriggers), - "triggers_history" => Some(Self::TriggersHistory), - "git_sync" | "github_app" => Some(Self::GitSync), - "capture" => Some(Self::Capture), - "drafts" => Some(Self::Drafts), - "favorites" => Some(Self::Favorites), - "inputs" => Some(Self::Inputs), - "job_helpers" => Some(Self::JobHelpers), - "concurrency_groups" => Some(Self::ConcurrencyGroups), - "oidc" => Some(Self::Oidc), - "openapi" => Some(Self::Openapi), - "acls" => Some(Self::Acls), - "raw_apps" => Some(Self::RawApps), - "agent_workers" => Some(Self::AgentWorkers), - "mcp" => Some(Self::Mcp), - "docs" => Some(Self::Docs), - _ => None, - } - } -} - -/// Available scope actions -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ScopeAction { - Read, // GET operations, list, view - Write, // POST, PUT, PATCH, DELETE operations, create, update, delete - Run, // Special action for running (scripts, flows, etc.) - Cancel, // Cancelling jobs (`CANCEL_PATH_ACTIONS`); covered by `jobs:write` -} - -impl ScopeAction { - pub fn as_str(&self) -> &'static str { - match self { - Self::Read => "read", - Self::Write => "write", - Self::Run => "run", - Self::Cancel => "cancel", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "read" => Some(Self::Read), - "write" => Some(Self::Write), - "delete" => Some(Self::Write), - "run" => Some(Self::Run), - "cancel" => Some(Self::Cancel), - _ => None, - } - } - - /// Check if this action includes another action - /// Write includes Read - pub fn includes(&self, other: &ScopeAction) -> bool { - match (self, other) { - (ScopeAction::Write, ScopeAction::Read) => true, - (ScopeAction::Run, ScopeAction::Read) => true, - (ScopeAction::Write, ScopeAction::Cancel) => true, - (a, b) => a == b, - } - } -} +pub use windmill_common::scopes::{ScopeAction, ScopeDefinition, ScopeDomain}; pub fn check_route_access( token_scopes: &[String], @@ -1293,6 +833,60 @@ pub fn scope_for_route(method: &str, path: &str) -> Option { }) } +/// Routes the runtime of a job calls about that job alone: progress, its root id, its +/// resume and approval urls, a workflow-as-code checkpoint or task. A job token restricted +/// by `job_token_scopes` keeps these whatever its scopes, or the job could not run at all, +/// but only for its own job: the job id in the path must be the token's. +pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uuid::Uuid) -> bool { + let Some(rest) = route_path.strip_prefix("/api/w/") else { + return false; + }; + let Some((_workspace, rest)) = rest.split_once('/') else { + return false; + }; + let segments: Vec<&str> = rest.split('/').collect(); + let (method_ok, id_index) = match segments.as_slice() { + ["jobs_u", "get" | "get_root_job_id", ..] => (http_method == "GET", 2), + ["job_metrics", "set_progress", ..] => (http_method == "POST", 2), + ["job_metrics", "get_progress", ..] => (http_method == "GET", 2), + ["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls", ..] => (http_method == "GET", 2), + ["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3), + ["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3), + _ => return false, + }; + method_ok + && segments + .get(id_index) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .is_some_and(|id| id == job_id) +} + +/// Routes reading the state of a flow run: step results (`results.x` in input transforms, +/// loop and branch results) and the run's user state. The flow orchestrator evaluates a +/// step's inputs with the token of the step that just finished, and the SDK reads user state +/// at the root job, so a restricted job token keeps these for every job of its own run. +/// Returns the job id the path names; the caller checks it against the token's lineage. +pub fn flow_run_read_route_job(route_path: &str, http_method: &str) -> Option { + let rest = route_path.strip_prefix("/api/w/")?; + let (_workspace, rest) = rest.split_once('/')?; + let segments: Vec<&str> = rest.split('/').collect(); + let id = match segments.as_slice() { + ["jobs" | "jobs_u", "result_by_id", id, ..] if http_method == "GET" => id, + ["jobs_u", "completed", "get_result" | "get_result_maybe", id, ..] + if http_method == "GET" => + { + id + } + ["jobs" | "jobs_u", "flow", "user_states", id, ..] + if http_method == "GET" || http_method == "POST" => + { + id + } + _ => return None, + }; + uuid::Uuid::parse_str(id).ok() +} + /// Helper function to check if scopes allow access to a route pub fn check_scopes_for_route( token_scopes: Option<&[String]>, @@ -1312,6 +906,63 @@ pub fn check_scopes_for_route( mod tests { use super::*; + #[test] + fn own_job_runtime_routes_admit_only_the_tokens_job() { + let own = uuid::Uuid::new_v4(); + let other = uuid::Uuid::new_v4(); + let ok = |path: &str, method: &str| is_own_job_runtime_route(path, method, own); + assert!(ok( + &format!("/api/w/ws/job_metrics/set_progress/{own}"), + "POST" + )); + assert!(ok( + &format!("/api/w/ws/jobs_u/get_root_job_id/{own}"), + "GET" + )); + assert!(ok(&format!("/api/w/ws/jobs/resume_urls/{own}/0"), "GET")); + assert!(ok( + &format!("/api/w/ws/jobs/wac/inline_checkpoint/{own}"), + "POST" + )); + assert!(ok( + &format!("/api/w/ws/jobs/run/workflow_as_code/{own}/main"), + "POST" + )); + assert!(!ok( + &format!("/api/w/ws/job_metrics/set_progress/{other}"), + "POST" + )); + assert!(!ok(&format!("/api/w/ws/jobs_u/get/{own}"), "POST")); + assert!(!ok(&format!("/api/w/ws/jobs/run/p/{own}"), "POST")); + assert!(!ok("/api/w/ws/variables/get_value/u/admin/secret", "GET")); + let run = |path: &str, method: &str| flow_run_read_route_job(path, method); + assert_eq!( + run(&format!("/api/w/ws/jobs/result_by_id/{other}/b"), "GET"), + Some(other) + ); + assert_eq!( + run( + &format!("/api/w/ws/jobs_u/completed/get_result/{other}"), + "GET" + ), + Some(other) + ); + assert_eq!( + run( + &format!("/api/w/ws/jobs/flow/user_states/{other}/k"), + "POST" + ), + Some(other) + ); + assert_eq!( + run( + &format!("/api/w/ws/jobs_u/completed/delete/{other}"), + "POST" + ), + None + ); + } + #[test] fn test_scope_definition_parsing() { let scope = ScopeDefinition::from_scope_string("jobs:read").unwrap(); diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 518ad4e259..1007cfd5b1 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -804,6 +804,24 @@ async fn create_flow( check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok()); + let job_token_scopes = nf + .job_token_scopes + .as_ref() + .and_then(|scopes| scopes.as_deref()) + .map(windmill_common::scopes::validate_job_token_scopes) + .transpose()?; + let restricts_steps = windmill_common::scopes::validate_flow_step_job_token_scopes( + &serde_json::from_str::(nf.value.get()) + .map_err(|e| windmill_common::error::Error::BadRequest(e.to_string()))?, + )?; + if job_token_scopes.is_some() || restricts_steps { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } + if restricts_steps { + windmill_common::feature_usage::log_feature_usage("job_token_scopes", "deploy", "step:set"); + } let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( nf.on_behalf_of_email.as_deref(), nf.on_behalf_of.as_deref(), @@ -824,14 +842,14 @@ async fn create_flow( dedicated_worker, visible_to_runner_only, ws_error_handler_muted, value, schema, edited_by, edited_at, labels, - on_behalf_of, on_behalf_of_email + on_behalf_of, on_behalf_of_email, job_token_scopes ) VALUES ( $1, $2, $3, $4, NULL, '', $5, $6, $7, $8, $9, $10::text::json, $11, now(), $12, - $13, $14 + $13, $14, $15 )"#, w_id, nf.path, @@ -847,9 +865,11 @@ async fn create_flow( nf.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; + windmill_common::scopes::log_job_token_scopes_deploy("flow", job_token_scopes.as_deref()); let version = sqlx::query_scalar!( "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) @@ -974,6 +994,7 @@ async fn create_flow( None, None, None, + None, ) .await?; @@ -1153,7 +1174,7 @@ async fn get_flow_version( let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", @@ -1213,6 +1234,7 @@ async fn get_flow_version_by_id( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, @@ -1368,6 +1390,27 @@ async fn update_flow( let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?; let is_new_path = nf.path != flow_path; let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok()); + // Absent keeps the deployed value: a client unaware of the setting must not drop a + // restriction by saving the flow. + let set_job_token_scopes = nf.job_token_scopes.is_some(); + let job_token_scopes = nf + .job_token_scopes + .as_ref() + .and_then(|scopes| scopes.as_deref()) + .map(windmill_common::scopes::validate_job_token_scopes) + .transpose()?; + let restricts_steps = windmill_common::scopes::validate_flow_step_job_token_scopes( + &serde_json::from_str::(nf.value.get()) + .map_err(|e| windmill_common::error::Error::BadRequest(e.to_string()))?, + )?; + if job_token_scopes.is_some() || restricts_steps { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } + if restricts_steps { + windmill_common::feature_usage::log_feature_usage("job_token_scopes", "deploy", "step:set"); + } let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( nf.on_behalf_of_email.as_deref(), nf.on_behalf_of.as_deref(), @@ -1402,7 +1445,8 @@ async fn update_flow( edited_at = now(), labels = COALESCE($13, labels), on_behalf_of = $14, - on_behalf_of_email = $15 + on_behalf_of_email = $15, + job_token_scopes = CASE WHEN $16 THEN $17 ELSE job_token_scopes END WHERE path = $11 AND workspace_id = $12", if is_new_path { flow_path } else { &nf.path }, @@ -1420,19 +1464,24 @@ async fn update_flow( nf.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + set_job_token_scopes, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await .map_err(|e| { error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}")) })?; + if set_job_token_scopes { + windmill_common::scopes::log_job_token_scopes_deploy("flow", job_token_scopes.as_deref()); + } if is_new_path { // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, @@ -1696,6 +1745,7 @@ async fn update_flow( None, None, None, + None, ) .await?; @@ -1871,6 +1921,7 @@ async fn get_flow_by_path( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, @@ -1912,6 +1963,7 @@ async fn get_flow_by_path( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, @@ -2336,6 +2388,7 @@ mod tests { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "b".to_string(), @@ -2371,6 +2424,7 @@ mod tests { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, FlowModule { id: "c".to_string(), @@ -2406,6 +2460,7 @@ mod tests { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }, ], failure_module: Some(Box::new(FlowModule { @@ -2440,6 +2495,7 @@ mod tests { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, })), preprocessor_module: None, same_worker: false, diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 1a750c38ba..8c13483e41 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -124,7 +124,9 @@ pub async fn drop_unclaimable_run_lineage( /// The jobs of `referenced` that `authed` cannot claim as its own run lineage: anything but the /// token's own job and that job's `parent_job`, `root_job` and `flow_innermost_root_job`, or for -/// a workspace admin anything outside the workspace. +/// a workspace admin anything outside the workspace. A restricted job token never gets the admin +/// latitude: its flow-run routes trust this lineage (`job_in_same_flow_run`), so a claimed +/// unrelated run would escape its scopes. pub async fn unclaimable_run_lineage( db: &DB, w_id: &str, @@ -157,7 +159,8 @@ pub async fn unclaimable_run_lineage( if referenced.is_empty() { return Ok(referenced); } - let in_workspace = if authed.is_admin { + let restricted_job_token = authed.job_id.is_some() && authed.scopes.is_some(); + let in_workspace = if authed.is_admin && !restricted_job_token { sqlx::query_scalar!( "SELECT id FROM v2_job WHERE id = ANY($1) AND workspace_id = $2", &referenced, @@ -865,6 +868,7 @@ pub async fn run_flow<'c>( chat_input_enabled, early_return, labels, + job_token_scopes, .. } = flow_version_info; @@ -914,6 +918,7 @@ pub async fn run_flow<'c>( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -924,6 +929,7 @@ pub async fn run_flow<'c>( version, apply_preprocessor, labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -950,6 +956,7 @@ pub async fn run_flow<'c>( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; @@ -1141,6 +1148,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -1176,6 +1184,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api-jobs/src/job_metrics.rs b/backend/windmill-api-jobs/src/job_metrics.rs index abb95693f5..ddbe5a047a 100644 --- a/backend/windmill-api-jobs/src/job_metrics.rs +++ b/backend/windmill-api-jobs/src/job_metrics.rs @@ -173,10 +173,33 @@ struct JobProgressSetRequest { } async fn set_job_progress( + authed: windmill_api_auth::ApiAuthed, Extension(db): Extension, Path((w_id, job_id)): Path<(String, Uuid)>, Json(JobProgressSetRequest { percent, flow_job_id }): Json, ) -> error::JsonResult<()> { + // A restricted job token reaches this route without a scope only for its own job + // (`is_own_job_runtime_route`), so the flow it reports progress to must be its own too. + if let (Some(flow_job_id), Some(token_job), Some(_)) = + (flow_job_id, authed.job_id, &authed.scopes) + { + let own_flow = sqlx::query_scalar!( + "SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) FROM v2_job + WHERE id = $1 AND workspace_id = $3", + token_job, + flow_job_id, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten() + .unwrap_or(false); + if !own_flow { + return Err(error::Error::PermissionDenied(format!( + "flow job {flow_job_id} is not this job's flow" + ))); + } + } // If flow_job_id exists, than we should modify flow_status of corresponding module // Individual jobs and flows are handled differently if let Some(flow_job_id) = flow_job_id { diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index 6389134031..d60edc3a3f 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -112,6 +112,8 @@ pub struct ExportableQueuedJob { pub preprocessed: Option, pub args: Option>>, pub labels: Option>, + #[serde(default)] + pub job_token_scopes: Option>, pub pre_run_error: Option, // v2_job_queue columns (excluding workspace_id and id/created_at/tag/priority) @@ -276,6 +278,7 @@ pub async fn export_queued_jobs( v2_job.preprocessed, v2_job.args as "args: _", v2_job.labels, + job_perms.job_token_scopes as "job_token_scopes?", v2_job.pre_run_error, v2_job_queue.started_at, @@ -298,6 +301,7 @@ pub async fn export_queued_jobs( concurrency_key.key as "concurrency_key?" FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id + LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id LEFT JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id @@ -445,6 +449,23 @@ pub async fn import_queued_jobs( )); } + // Imported rows bypass `push`, so nothing would cap their tokens: a restricted job token + // could otherwise queue arbitrary jobs that run with its owner's full permissions. + if authed.job_id.is_some() && authed.scopes.is_some() { + return Err(error::Error::PermissionDenied( + "A restricted job token cannot import queued jobs".to_string(), + )); + } + // An imported job gets no `job_perms` row, so its token would be minted without the + // restriction it was queued with. + if let Some(job) = jobs.iter().find(|job| job.job_token_scopes.is_some()) { + return Err(error::Error::BadRequest(format!( + "Queued job {} restricts its job token and cannot be imported; run it again on \ + this instance instead", + job.id + ))); + } + let mut tx = user_db.begin(&authed).await?; for job in jobs { diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 6edc6e2e59..f622c4bb91 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -899,6 +899,7 @@ async fn is_noop_deploy_against_parent( ns: &NewScript, parent: &Script, resolved_on_behalf_of: Option<&str>, + resolved_job_token_scopes: Option<&[String]>, db: &DB, ) -> Result { if parent.archived || parent.deleted { @@ -952,6 +953,8 @@ async fn is_noop_deploy_against_parent( // caller-intent flag (auto-resolve parent), not script state auto_parent: _, labels, + // resolved against the deployed value into `resolved_job_token_scopes`, compared below + job_token_scopes: _, // caller-intent flag (preserve user drafts on CLI/git-sync deploys); // transient, never persisted, does not change what the script *is* skip_draft_deletion: _, @@ -1025,6 +1028,9 @@ async fn is_noop_deploy_against_parent( if resolved_on_behalf_of != parent.on_behalf_of.as_deref() { return Ok(false); } + if resolved_job_token_scopes != parent.job_token_scopes.as_deref() { + return Ok(false); + } // Both of a dbt script's derived fields are compared as they WOULD BE STORED, // not as they arrived: the schema comes from the descriptor and the clients // cannot derive it (`windmill-parser-wasm` has no dbt arm), so they send the @@ -1492,6 +1498,37 @@ async fn create_script_internal<'c>( parent_adopted_from_retired_path = ns.parent_hash.is_some(); } + // Absent keeps the previous version's value, read once the parent is settled (a rename + // adopts its source head above), so a client unaware of the setting cannot drop a + // restriction by redeploying or renaming. + let resolved_job_token_scopes: Option> = match (&ns.job_token_scopes, &ns.parent_hash) { + (Some(Some(scopes)), _) => { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + Some(windmill_common::scopes::validate_job_token_scopes(scopes)?) + } + (Some(None), _) => None, + (None, Some(parent_hash)) => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", + parent_hash.0, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(), + (None, None) => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \ + AND deleted = false ORDER BY created_at DESC LIMIT 1", + &ns.path, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(), + }; + ns.job_token_scopes = Some(resolved_job_token_scopes.clone()); + // Before hashing, so the hash and the no-op check see the schema that gets stored. // `{}` counts as absent: an agent filling every tool argument sends it for "none". let schema_absent = ns.schema.as_ref().is_none_or(|s| { @@ -1620,8 +1657,14 @@ async fn create_script_internal<'c>( // CLI pushes must not produce phantom commits on the downstream // git repository. if skip_if_noop - && is_noop_deploy_against_parent(&ns, &ps, resolved_on_behalf_of.as_deref(), &db) - .await? + && is_noop_deploy_against_parent( + &ns, + &ps, + resolved_on_behalf_of.as_deref(), + resolved_job_token_scopes.as_deref(), + &db, + ) + .await? { tracing::info!( workspace_id = %w_id, @@ -2197,8 +2240,8 @@ async fn create_script_internal<'c>( content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ - delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", + delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email, job_token_scopes) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42)", &w_id, &hash.0, ns.path, @@ -2242,9 +2285,14 @@ async fn create_script_internal<'c>( ns.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + resolved_job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; + windmill_common::scopes::log_job_token_scopes_deploy( + "script", + resolved_job_token_scopes.as_deref(), + ); // A lock that is not left to a dependency job queues none, so this is the only place its hash // can be recorded. `try_skip_relock` treats a missing hash for an imported script as changed, @@ -2954,6 +3002,7 @@ async fn create_script_internal<'c>( None, None, None, + None, ) .await?; @@ -3077,6 +3126,7 @@ async fn create_script_internal<'c>( None, None, None, + None, ) .await?; tracing::info!("pushed auto-build binary job {job_id} for {script_path}"); diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index f762fda175..988c430b20 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -235,6 +235,7 @@ async fn run_datatable_migration_job( args.insert("database".to_string(), database_arg.clone()); let push_args = PushArgs { extra: None, args: &args }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, mut tx) = push( db, PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), @@ -278,6 +279,7 @@ async fn run_datatable_migration_job( None, None, None, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 81fead981b..ac838d772b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -7637,7 +7637,7 @@ async fn clone_scripts( dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, - on_behalf_of, on_behalf_of_email, assets, modules + on_behalf_of, on_behalf_of_email, assets, modules, job_token_scopes ) SELECT $1, hash, path, parent_hashes, summary, description, content, @@ -7669,7 +7669,7 @@ async fn clone_scripts( UNION ALL SELECT 1 FROM password p WHERE p.email = on_behalf_of AND p.super_admin)) - END, on_behalf_of_email, assets, modules + END, on_behalf_of_email, assets, modules, job_token_scopes FROM script WHERE workspace_id = $2"#, target_workspace_id, @@ -8027,7 +8027,8 @@ async fn clone_flows( workspace_id, path, summary, description, value, edited_by, edited_at, archived, schema, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, - concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs + concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs, + job_token_scopes ) SELECT $2, path, summary, description, value, edited_by, edited_at, archived, schema, extra_perms, NULL, tag, @@ -8053,7 +8054,7 @@ async fn clone_flows( UNION ALL SELECT 1 FROM password p WHERE p.email = on_behalf_of AND p.super_admin)) - END, on_behalf_of_email, lock_error_logs + END, on_behalf_of_email, lock_error_logs, job_token_scopes FROM flow WHERE workspace_id = $1", source_workspace_id, diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 8ca98f66d1..7e2958bb58 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -408,8 +408,8 @@ pub(crate) async fn change_workspace_id( info!("Duplicating flow table rows"); sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) - SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes) + SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes FROM flow WHERE workspace_id = $2", &rw.new_id, &old_id diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d9aeb63707..3b915f0b55 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -30150,6 +30150,12 @@ components: items: type: string default: [] + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job." inherited_labels: type: array items: @@ -30307,6 +30313,12 @@ components: type: array items: type: string + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it." skip_draft_deletion: type: boolean description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path." @@ -36165,6 +36177,12 @@ components: items: type: string default: [] + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job." inherited_labels: type: array items: @@ -36211,6 +36229,12 @@ components: type: array items: type: string + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it." required: - path # Like OpenFlowWPath but `path` is optional: on update the flow is identified by @@ -36247,6 +36271,12 @@ components: type: array items: type: string + job_token_scopes: + type: array + nullable: true + items: + type: string + description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it." FlowPreview: type: object diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs index d2e76dcca0..b844e8c2bf 100644 --- a/backend/windmill-api/src/agent_runs.rs +++ b/backend/windmill-api/src/agent_runs.rs @@ -84,6 +84,7 @@ pub(crate) async fn run_agent( )?; let push_authed = authed.clone().into(); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, mut tx) = push( &db, PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), @@ -118,6 +119,7 @@ pub(crate) async fn run_agent( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 42c2fcb2f2..57e75b3f9f 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -760,6 +760,7 @@ async fn push_run_flow( let path = subject.path.clone(); let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, tx, @@ -790,6 +791,7 @@ async fn push_run_flow( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 69349a4134..f276e6d143 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -2756,6 +2756,7 @@ async fn create_app_internal<'a>( None, None, None, + None, ) .await?; tracing::info!("Pushed app dependency job {}", dependency_job_uuid); @@ -3897,6 +3898,7 @@ async fn update_app_internal<'a>( None, None, None, + None, ) .await?; tracing::info!("Pushed app dependency job {}", dependency_job_uuid); @@ -4491,6 +4493,10 @@ async fn execute_component( let app_trigger = (!is_preview).then(|| TriggerMetadata::new(Some(path.to_string()), JobTriggerKind::App)); + let scope_ceiling = match opt_authed.as_ref() { + Some(authed) => windmill_api_auth::caller_scope_ceiling(&db, authed).await?, + None => None, + }; let (uuid, mut tx) = push( &db, tx, @@ -4526,6 +4532,7 @@ async fn execute_component( end_user_email, app_trigger, None, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api/src/apps_raw_bundle.rs b/backend/windmill-api/src/apps_raw_bundle.rs index f50f94f8eb..61dc4b861d 100644 --- a/backend/windmill-api/src/apps_raw_bundle.rs +++ b/backend/windmill-api/src/apps_raw_bundle.rs @@ -148,6 +148,7 @@ pub(crate) async fn bundle_raw_app_sources( args.insert("runnables".to_string(), runnables.to_owned()); let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, tx, @@ -191,6 +192,7 @@ pub(crate) async fn bundle_raw_app_sources( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; diff --git a/backend/windmill-api/src/concurrency_groups.rs b/backend/windmill-api/src/concurrency_groups.rs index c94608ac7e..aab73c81d9 100644 --- a/backend/windmill-api/src/concurrency_groups.rs +++ b/backend/windmill-api/src/concurrency_groups.rs @@ -56,6 +56,7 @@ async fn get_concurrency_key( .get_authed(Some(job.workspace_id.clone()), &token) .await .ok_or_else(not_found)?; + windmill_api_auth::check_job_token_scope(&authed_in_workspace, || "jobs:read".to_string())?; require_job_read_access( &db, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 00139215c4..b3a5e86b46 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -7353,6 +7353,7 @@ pub async fn restart_flow( let tx = PushIsolationLevel::Isolated(user_db, authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -7390,6 +7391,7 @@ pub async fn restart_flow( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -7527,7 +7529,11 @@ pub async fn run_workflow_as_code( extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint)); let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) }; check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?; - check_scopes(&authed, || format!("jobs:run"))?; + // A job running its own task is its runtime, not a new run: a restricted job token keeps + // it (`is_own_job_runtime_route`), and the task inherits that job's restriction at push. + if authed.job_id != Some(job_id) { + check_scopes(&authed, || format!("jobs:run"))?; + } // The task becomes a child of `job_id`, runs its code and writes into its flow status, so // only that job itself (the SDK's `task` wrapper, on its `WM_TOKEN`) or an admin may push it. if authed.job_id != Some(job_id) && !authed.is_admin { @@ -7672,6 +7678,7 @@ pub async fn run_workflow_as_code( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -7702,6 +7709,7 @@ pub async fn run_workflow_as_code( None, None, None, + scope_ceiling.as_deref(), ) .await?; @@ -7985,6 +7993,7 @@ pub async fn run_wait_result_job_by_path_get( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8015,6 +8024,7 @@ pub async fn run_wait_result_job_by_path_get( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8133,6 +8143,7 @@ pub async fn run_wait_result_script_by_path_internal( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8163,6 +8174,7 @@ pub async fn run_wait_result_script_by_path_internal( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8215,6 +8227,7 @@ pub async fn run_wait_result_script_by_hash( timeout, has_preprocessor, labels, + job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings }, .. @@ -8248,6 +8261,7 @@ pub async fn run_wait_result_script_by_hash( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8265,6 +8279,7 @@ pub async fn run_wait_result_script_by_hash( apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -8291,6 +8306,7 @@ pub async fn run_wait_result_script_by_hash( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8810,6 +8826,7 @@ async fn run_preview_script( let push_args = PushArgs { extra, args: &preview_args }; check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8860,6 +8877,7 @@ async fn run_preview_script( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8944,7 +8962,8 @@ async fn run_inline_script_by_path( token, db, w_id, - InlineScriptTarget::Path(script_path.to_path().to_string()), + // Resolved to a version by `run_inline_script_inner`. + InlineScriptTarget::Path { path: script_path.to_path().to_string(), hash: 0 }, body.args, Some(user_db), ) @@ -9007,6 +9026,42 @@ async fn run_inline_script_inner( args: Option>>, user_db: Option, ) -> error::Result { + // An inline run executes with the caller's own token, so it cannot honour a script's + // `job_token_scopes`: such a script only runs as a job. A path is resolved here, once, and + // the version checked is the version run. + let (target, restricted) = match target { + InlineScriptTarget::Path { path, .. } => { + let authed_ref = authed.to_authed_ref(); + let info = get_latest_deployed_hash_for_path( + user_db + .as_ref() + .map(|db| UserDbWithAuthed { db: db.clone(), authed: &authed_ref }), + db.clone(), + &w_id, + &path, + ) + .await?; + let restricted = info.job_token_scopes.is_some(); + ( + InlineScriptTarget::Path { path, hash: info.hash }, + restricted, + ) + } + InlineScriptTarget::Hash(hash) => { + let restricted = windmill_common::get_script_info_for_hash(None, &db, &w_id, hash) + .await? + .job_token_scopes + .is_some(); + (InlineScriptTarget::Hash(hash), restricted) + } + }; + if restricted { + return Err(Error::BadRequest( + "This script restricts its job token (job_token_scopes) and cannot be run inline; \ + run it as a job instead" + .to_string(), + )); + } let utils = get_worker_internal_server_inline_utils()?; let authed_owned: windmill_common::db::Authed = authed.clone().into(); let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams { @@ -9193,6 +9248,7 @@ async fn run_bundle_preview_script( // tracing::info!("is_tar 1: {is_tar}"); // hmap.insert("") + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, ntx) = push( &db, ltx, @@ -9239,6 +9295,7 @@ async fn run_bundle_preview_script( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; job_id = Some(uuid); @@ -9358,6 +9415,7 @@ async fn push_dependencies_job( req.temp_script_refs .map(|v| hm.insert("temp_script_refs".to_owned(), to_raw_value(&v))); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -9392,6 +9450,7 @@ async fn push_dependencies_job( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -9490,6 +9549,7 @@ async fn push_flow_dependencies_job( req.temp_script_refs .map(|v| args_map.insert("temp_script_refs".to_string(), to_raw_value(&v))); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -9520,6 +9580,7 @@ async fn push_flow_dependencies_job( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -9574,6 +9635,7 @@ async fn add_batch_jobs( ) -> error::JsonResult> { require_super_admin(&db, &authed).await?; + let mut job_token_scopes: Option> = None; let ( hash, path, @@ -9605,11 +9667,13 @@ async fn add_batch_jobs( dedicated_worker, timeout, runnable_settings, + job_token_scopes: script_job_token_scopes, .. // TODO: consider on_behalf_of_email and created_by for batch jobs } = get_latest_deployed_hash_for_path(Some(db_authed), db.clone(), &w_id, &path) .await? .prefetch_cached(&db) .await?; + job_token_scopes = script_job_token_scopes; ( Some(script_hash), Some(path), @@ -9656,7 +9720,7 @@ async fn add_batch_jobs( } else if let Some(path) = batch_info.path { let mut tx = user_db.clone().begin(&authed).await?; let value_json = sqlx::query!( - "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\" FROM flow + "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\", flow.job_token_scopes FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] LEFT JOIN flow_version_lite @@ -9667,6 +9731,7 @@ async fn add_batch_jobs( .fetch_optional(&mut *tx) .await? .ok_or_else(|| Error::internal_err(format!("not found flow at path {:?}", path)))?; + job_token_scopes = value_json.job_token_scopes; let value = serde_json::from_str::(value_json.value.get()).map_err(|err| { Error::internal_err(format!( @@ -9679,6 +9744,11 @@ async fn add_batch_jobs( "Path is required if no value is not provided" ))? }; + if windmill_common::scopes::validate_flow_step_job_token_scopes(&value)? { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } add_virtual_items_if_necessary(&mut value.modules); let flow_status = FlowStatus::new(&value); ( @@ -9722,7 +9792,8 @@ async fn add_batch_jobs( let language = language.unwrap_or(ScriptLang::Deno); let tag = if let Some(dedicated_worker) = dedicated_worker { - if dedicated_worker && path.is_some() { + // Same rule as `push`: a dedicated worker would run it with its own unscoped token. + if dedicated_worker && path.is_some() && job_token_scopes.is_none() { windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap()) } else { format!("{}", language.as_str()) @@ -9790,8 +9861,8 @@ async fn add_batch_jobs( .await?; sqlx::query!( - "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id) - SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8", + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, job_token_scopes) + SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8, $9", &uuids, authed.email, authed.username, @@ -9800,6 +9871,7 @@ async fn add_batch_jobs( &[], &[], w_id, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; @@ -9850,6 +9922,13 @@ async fn run_preview_flow_job( // jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &raw_flow.path)?; + // Step restrictions apply to a preview as they do to a deployed run, so they are checked + // the same way: an invalid entry or an older worker would leave a step unrestricted. + if windmill_common::scopes::validate_flow_step_job_token_scopes(&raw_flow.value)? { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } // A builder must be able to test what it composes, but the submitted value is not the stored // one: without this the preview is a way to run inline code the write path refuses. if authed.is_operator { @@ -9892,6 +9971,7 @@ async fn run_preview_flow_job( check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed) .await?; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -9926,6 +10006,7 @@ async fn run_preview_flow_job( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; @@ -10149,6 +10230,7 @@ async fn run_dynamic_select( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -10195,6 +10277,7 @@ async fn run_dynamic_select( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -10270,6 +10353,7 @@ pub async fn run_job_by_hash_inner( delete_after_use, delete_after_secs, labels, + job_token_scopes, .. } = script_info; @@ -10302,6 +10386,7 @@ pub async fn run_job_by_hash_inner( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -10319,6 +10404,7 @@ pub async fn run_job_by_hash_inner( apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -10345,6 +10431,7 @@ pub async fn run_job_by_hash_inner( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 472c7bbec9..8f3962da13 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -372,7 +372,8 @@ impl McpBackend for WindmillBackend { token: &str, workspace_id: &str, ) -> BackendResult { - self.auth_cache + let authed = self + .auth_cache .get_authed(Some(workspace_id.to_string()), token) .await .ok_or_else(|| { @@ -383,7 +384,11 @@ impl McpBackend for WindmillBackend { ), None, ) - }) + })?; + // Job token scopes never include MCP scopes, so a restricted job token gets none. + windmill_api_auth::check_job_token_scope(&authed, || "mcp:all".to_string()) + .map_err(|e| ErrorData::invalid_params(e.to_string(), None))?; + Ok(authed) } async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult { diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index 26eb06c8e8..b7f83dfd96 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -891,8 +891,8 @@ async fn offboard_user_from_workspace<'c>( let flows_reassigned = sqlx::query_scalar!( r#"WITH inserted AS ( INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) - SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes) + SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index a7f9aeda89..39a213c860 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -665,8 +665,8 @@ async fn update_username_in_workpsace<'c>( // ---- flows ---- sqlx::query!( r#"INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes) + SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, new_username, diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index ea79aaa572..3c3e965248 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -119,6 +119,8 @@ struct ScriptMetadata { pub debouncing_settings: DebouncingSettings, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, #[serde(skip_serializing_if = "is_empty_extra_perms")] pub extra_perms: serde_json::Value, } @@ -906,6 +908,7 @@ pub(crate) async fn tarball_workspace( .then_some(true), modules: script.modules, labels: script.labels, + job_token_scopes: script.job_token_scopes, // Same opt-in contract as flow/app: the tarball only surfaces // ACLs when `?preserve_extra_perms=true`. Passing `Null` lets the // `is_empty_extra_perms` skip-serializer drop the field entirely. @@ -966,7 +969,7 @@ pub(crate) async fn tarball_workspace( { let flows = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.archived = false", diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 86b7f173ac..17310bdbbc 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -362,6 +362,10 @@ pub struct JobPerms { pub groups: Vec, pub folders: Vec, pub end_user_email: Option, + /// The job's effective scopes, stored on its `job_perms` row at push and minted into its + /// token. + #[serde(default)] + pub job_token_scopes: Option>, } impl From for Authed { @@ -654,7 +658,9 @@ pub async fn get_job_perms<'a, E: sqlx::PgExecutor<'a>>( ) -> sqlx::Result> { sqlx::query_as!( JobPerms, - "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email FROM job_perms WHERE job_id = $1 AND workspace_id = $2", + "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email, + job_token_scopes + FROM job_perms WHERE job_id = $1 AND workspace_id = $2", job_id, w_id ) @@ -745,17 +751,29 @@ pub async fn create_token_for_owner( } else { get_job_perms(db, job_id, w_id).await }; - let job_authed = match job_perms { - Ok(Some(jp)) => jp.into(), - _ => { + let (job_authed, job_token_scopes) = match job_perms { + Ok(Some(mut jp)) => { + let scopes = jp.job_token_scopes.take(); + (jp.into(), scopes) + } + // A failed read must not mint as if the job had no row: that would drop its restriction. + Err(e) => { + return Err(Error::internal_err(format!( + "Could not read permissions for job {job_id}: {e:#}" + ))) + } + // Push writes a job's `job_perms` row and its scopes in one statement, so a job with no + // row was never restricted. + Ok(None) => { tracing::warn!("Could not get permissions for job {job_id} from job_perms table, getting permissions directly..."); - fetch_authed_from_permissioned_as(owner, email, w_id, db) + let authed = fetch_authed_from_permissioned_as(owner, email, w_id, db) .await .map_err(|e| { Error::internal_err(format!( "Could not get permissions directly for job {job_id}: {e:#}" )) - })? + })?; + (authed, None) } }; @@ -766,7 +784,7 @@ pub async fn create_token_for_owner( Some(*job_id), Some(label.to_string()), audit_span, - None, + crate::scopes::job_token_jwt_scopes(job_token_scopes), ) .await } diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 796d1c999a..d437881b7b 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -136,6 +136,7 @@ pub async fn script_path_to_payload<'e>( timeout, has_preprocessor, labels, + job_token_scopes, .. } = script_info; @@ -153,6 +154,7 @@ pub async fn script_path_to_payload<'e>( debouncing_settings, concurrency_settings, labels, + job_token_scopes, }, tag, delete_after_use, @@ -203,7 +205,7 @@ pub async fn get_payload_tag_from_prefixed_path( None, ) } else { - let FlowVersionInfo { dedicated_worker, tag, version, labels, .. } = + let FlowVersionInfo { dedicated_worker, tag, version, labels, job_token_scopes, .. } = get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?; ( JobPayload::Flow { @@ -212,6 +214,7 @@ pub async fn get_payload_tag_from_prefixed_path( apply_preprocessor: false, version, labels, + job_token_scopes, }, tag, None, @@ -481,7 +484,8 @@ pub struct RunInlinePreviewScriptFnParams { } pub enum InlineScriptTarget { - Path(String), + /// A script addressed by path, pinned to the version the caller resolved and checked. + Path { path: String, hash: i64 }, Hash(i64), } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 1013baa2e0..704948dd87 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -127,6 +127,7 @@ pub mod runnable_settings; pub mod runnables; pub mod schedule; pub mod schema; +pub mod scopes; pub mod scripts; pub mod secret_backend; pub mod sensitive_log_masks; @@ -2247,6 +2248,7 @@ pub struct ScriptHashInfo { pub on_behalf_of: Option, pub created_by: String, pub labels: Option>, + pub job_token_scopes: Option>, #[sqlx(flatten)] pub runnable_settings: SR, } @@ -2340,6 +2342,7 @@ impl ScriptHashInfo { on_behalf_of: self.on_behalf_of, created_by: self.created_by, labels: self.labels, + job_token_scopes: self.job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( self.runnable_settings.concurrency_key, @@ -2704,6 +2707,7 @@ async fn get_script_info_for_hash_inner<'e, E: sqlx::PgExecutor<'e>>( on_behalf_of, created_by, labels, + job_token_scopes, path FROM script WHERE hash = $1 AND workspace_id = $2", ) @@ -2725,6 +2729,7 @@ pub struct FlowVersionInfo { pub edited_by: String, pub dedicated_worker: Option, pub labels: Option>, + pub job_token_scopes: Option>, } impl FlowVersionInfo { @@ -2868,7 +2873,8 @@ pub fn get_flow_version_info_from_version< flow.dedicated_worker, flow.on_behalf_of, flow.edited_by, - flow.labels + flow.labels, + flow.job_token_scopes FROM flow_version INNER JOIN flow @@ -3003,9 +3009,10 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( Option, Option, Option>, + Option>, )> { let r_o = sqlx::query!( - "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels FROM script + "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels, job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false) ORDER BY created_at DESC LIMIT 1", script_path, @@ -3037,6 +3044,7 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( on_behalf_of, script.runnable_settings_handle, script.labels, + script.job_token_scopes, )) } diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index 2ae666c14e..930b718b7e 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -19,6 +19,10 @@ pub const MIN_VERSION_SUPPORTS_BINARY_PREBUILD: VC = vc(1, 789, 0, "Auto-build b // dependency job asks bun for a v1 lockfile, and refuses to store one bun raised anyway. // Must name the release this ships in. pub const MIN_VERSION_SUPPORTS_BUN_LOCKFILE_V2: VC = vc(1, 794, 0, "Bun v2 lockfiles"); +// A worker that predates `job_token_scopes` mints the token of every job it pulls without +// them, so a restriction set while one is live is silently ignored on the jobs it runs. Must +// name the release this ships in. +pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted job tokens"); pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing"); pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing"); diff --git a/backend/windmill-common/src/scopes.rs b/backend/windmill-common/src/scopes.rs new file mode 100644 index 0000000000..35f4497fac --- /dev/null +++ b/backend/windmill-common/src/scopes.rs @@ -0,0 +1,748 @@ +/* + * Author: Windmill Labs, Inc + * Copyright: Windmill Labs, Inc 2024 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +use crate::error::{Error, Result}; + +/// Comprehensive scope system for JWT token authorization +/// +/// Scopes follow the format: {domain}:{action}[:{resource}] +/// Examples: +/// - "jobs:read" - Read access to jobs +/// - "scripts:write:f/folder/*" - Write access to scripts in a folder +/// - "*" - Full access (superuser) + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopeDefinition { + pub domain: String, + pub action: String, + pub kind: Option, // For jobs:run:kind (optional) + pub resource: Option>, +} + +impl ScopeDefinition { + pub fn new( + domain: &str, + action: &str, + kind: Option<&str>, + resource: Option>, + ) -> Self { + Self { + domain: domain.to_string(), + action: action.to_string(), + kind: kind.map(|s| s.to_string()), + resource: resource, + } + } + + pub fn from_scope_string(scope: &str) -> Result { + let parts: Vec<&str> = scope.split(':').collect(); + + let into_owned_vec = |resources: &str| -> Vec { + let resources = resources + .split(",") + .collect_vec() + .into_iter() + .map(ToOwned::to_owned) + .collect_vec(); + + resources + }; + + match parts.len() { + 2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action + 3 => { + if parts[0] == "jobs" && parts[1] == "run" { + Ok(Self::new(parts[0], parts[1], Some(parts[2]), None)) + } else { + Ok(Self::new( + parts[0], + parts[1], + None, + Some(into_owned_vec(parts[2])), + )) + } + } + 4 => { + if parts[0] == "jobs" && parts[1] == "run" { + Ok(Self::new( + parts[0], + parts[1], + Some(parts[2]), + Some(into_owned_vec(parts[3])), + )) + } else { + Err(Error::BadRequest(format!( + "Invalid 4-part scope: {}", + scope + ))) + } + } + _ => Err(Error::BadRequest(format!( + "Invalid scope format: {}", + scope + ))), + } + } + + pub fn as_string(&self) -> String { + match (&self.kind, &self.resource) { + (Some(kind), Some(resource)) => { + format!( + "{}:{}:{}:{}", + self.domain, + self.action, + kind, + resource.join(",") + ) + } + (Some(kind), None) => { + format!("{}:{}:{}", self.domain, self.action, kind) + } + (None, Some(resource)) => { + format!("{}:{}:{}", self.domain, self.action, resource.join(",")) + } + (None, None) => format!("{}:{}", self.domain, self.action), + } + } + + pub fn includes(&self, other: &ScopeDefinition) -> bool { + if self.domain != other.domain { + return false; + } + + match (self.action.as_str(), other.action.as_str()) { + (a, b) if (a == "write" && b == "read") || (a == b) => {} + // Apps only: `write` can rewrite the app and its policy, so it also covers + // running its components. Not general — `jobs:write` must not grant + // `jobs:run`. The resource check below still confines it to the same app. + ("write", "run") if self.domain == "apps" => {} + ("write", "cancel") if self.domain == "jobs" => {} + _ => return false, + } + + if self.domain == "jobs" && self.action == "run" { + match (&self.kind, &other.kind) { + (Some(self_kind), Some(other_kind)) => { + if self_kind != other_kind { + return false; + } + } + (Some(_), None) => { + return false; + } + (None, _) => { + return true; + } + } + } + + match (&self.resource, &other.resource) { + (Some(self_resources), Some(other_resources)) => { + resources_match(self_resources, other_resources) + } + // A requirement naming no path is the whole domain, so only a grant that + // itself spans every path satisfies it. `*` is that grant — the scope UI + // accepts it as a resource path and `resources_match` already reads it as + // everything — while any listed path leaves the collection unauthorized. + (Some(self_resources), None) => self_resources.iter().any(|r| r == "*"), + (None, _) => true, + } + } +} + +fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool { + if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) { + return true; + } + + if scope_resources.len() <= 4 && accepted_resources.len() <= 4 { + return resources_match_small(scope_resources, accepted_resources); + } + + resources_match_large(scope_resources, accepted_resources) +} + +fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool { + for required in accepted_resources { + for scope_resource in scope_resources { + if resource_matches_pattern(scope_resource, required) { + return true; + } + } + } + false +} + +fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool { + let mut exact_matches = HashSet::new(); + let mut patterns = Vec::new(); + + for scope_resource in scope_resources { + if scope_resource.contains('*') { + patterns.push(scope_resource); + } else { + exact_matches.insert(scope_resource); + } + } + + for accepted_resource in accepted_resources { + if exact_matches.contains(accepted_resource) { + return true; + } + + for pattern in &patterns { + if resource_matches_pattern(pattern, accepted_resource) { + return true; + } + } + } + + false +} + +fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool { + if scope_resource == accepted_resource { + return true; + } + + let matches_wildcard = |pattern: &str, resource: &str| -> bool { + if !pattern.ends_with("/*") { + return false; + } + + let prefix = &pattern[..pattern.len() - 2]; + + if !resource.starts_with(prefix) { + return false; + } + + // If the resource is exactly the prefix, it matches + if resource.len() == prefix.len() { + return true; + } + + // If the resource is longer, the next character must be '/' for a valid match + // This prevents "u/user" from matching "u/use/*" + resource.chars().nth(prefix.len()) == Some('/') + }; + + // Check if either resource is a wildcard pattern and matches the other + matches_wildcard(scope_resource, accepted_resource) + || matches_wildcard(accepted_resource, scope_resource) +} + +// ───────────────────────────────────────────────────────────────── +// Route-level scope checking +// ───────────────────────────────────────────────────────────────── + +/// Available scope domains (top-level API categories) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScopeDomain { + // Core resource domains + Jobs, + Scripts, + /// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a + /// `data_metrics:read` token must reach only this route, never the broader + /// `/scripts` routes (some of which do no further scope check). + DataMetrics, + Flows, + FlowConversations, + Apps, + Variables, + Resources, + Schedules, + Folders, + Users, + Groups, + Workspaces, + + // Trigger domains + HttpTriggers, + WebsocketTriggers, + KafkaTriggers, + NatsTriggers, + MqttTriggers, + AmqpTriggers, + SqsTriggers, + GcpTriggers, + AzureTriggers, + PostgresTriggers, + EmailTriggers, + + // Native trigger domains + NativeTriggers, + TriggersHistory, + + // System domains + Audit, + Settings, + Workers, + ServiceLogs, + Configs, + OAuth, + AI, + AiEvals, // AI agent eval datasets + + Indexer, + Teams, // Microsoft Teams integration + GitSync, // Git synchronization + + // Special domains + Capture, // Webhook capture + Drafts, // Draft resources + Favorites, // User favorites + Inputs, // Input templates + JobHelpers, // Job helper functions + ConcurrencyGroups, // Concurrency groups + Oidc, // OpenID Connect + Openapi, // OpenAPI generation + + // Additional domains + Acls, // Granular access control lists + RawApps, // Raw application data + AgentWorkers, // Agent workers management + Mcp, // MCP + Docs, // Self-hosted documentation search (read-only) +} + +impl ScopeDomain { + pub fn as_str(&self) -> &'static str { + match self { + Self::Jobs => "jobs", + Self::Scripts => "scripts", + Self::DataMetrics => "data_metrics", + Self::Flows => "flows", + Self::FlowConversations => "flow_conversations", + Self::Apps => "apps", + Self::Variables => "variables", + Self::Resources => "resources", + Self::Schedules => "schedules", + Self::Folders => "folders", + Self::Users => "users", + Self::Groups => "groups", + Self::Workspaces => "workspaces", + Self::HttpTriggers => "http_triggers", + Self::WebsocketTriggers => "websocket_triggers", + Self::KafkaTriggers => "kafka_triggers", + Self::NatsTriggers => "nats_triggers", + Self::MqttTriggers => "mqtt_triggers", + Self::AmqpTriggers => "amqp_triggers", + Self::SqsTriggers => "sqs_triggers", + Self::GcpTriggers => "gcp_triggers", + Self::AzureTriggers => "azure_triggers", + Self::PostgresTriggers => "postgres_triggers", + Self::EmailTriggers => "email_triggers", + Self::NativeTriggers => "native_triggers", + Self::TriggersHistory => "triggers_history", + Self::Audit => "audit", + Self::Settings => "settings", + Self::Workers => "workers", + Self::ServiceLogs => "service_logs", + Self::Configs => "configs", + Self::OAuth => "oauth", + Self::AI => "ai", + Self::AiEvals => "ai_evals", + Self::Capture => "capture", + Self::Drafts => "drafts", + Self::Favorites => "favorites", + Self::Inputs => "inputs", + Self::JobHelpers => "job_helpers", + Self::ConcurrencyGroups => "concurrency_groups", + Self::Oidc => "oidc", + Self::Openapi => "openapi", + Self::Acls => "acls", + Self::RawApps => "raw_apps", + Self::AgentWorkers => "agent_workers", + Self::Indexer => "indexer", + Self::Teams => "teams", + Self::GitSync => "git_sync", + Self::Mcp => "mcp", + Self::Docs => "docs", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "jobs" | "jobs_u" => Some(Self::Jobs), + "scripts" => Some(Self::Scripts), + // A distinct domain, not an alias of `scripts` (see the enum variant): + // a `data_metrics:read` token must not reach the broader /scripts routes. + "data_metrics" => Some(Self::DataMetrics), + "flows" => Some(Self::Flows), + "flow_conversations" => Some(Self::FlowConversations), + "apps" | "apps_u" => Some(Self::Apps), + "variables" => Some(Self::Variables), + "resources" => Some(Self::Resources), + "schedules" => Some(Self::Schedules), + "folders" => Some(Self::Folders), + "users" => Some(Self::Users), + "groups" => Some(Self::Groups), + "workspaces" => Some(Self::Workspaces), + "http_triggers" => Some(Self::HttpTriggers), + "websocket_triggers" => Some(Self::WebsocketTriggers), + "kafka_triggers" => Some(Self::KafkaTriggers), + "nats_triggers" => Some(Self::NatsTriggers), + "mqtt_triggers" => Some(Self::MqttTriggers), + "amqp_triggers" => Some(Self::AmqpTriggers), + "sqs_triggers" => Some(Self::SqsTriggers), + "gcp_triggers" => Some(Self::GcpTriggers), + "azure_triggers" => Some(Self::AzureTriggers), + "postgres_triggers" => Some(Self::PostgresTriggers), + "email_triggers" => Some(Self::EmailTriggers), + "audit" => Some(Self::Audit), + "settings" => Some(Self::Settings), + "workers" => Some(Self::Workers), + "service_logs" => Some(Self::ServiceLogs), + "configs" => Some(Self::Configs), + "oauth" => Some(Self::OAuth), + "ai" => Some(Self::AI), + "ai_evals" => Some(Self::AiEvals), + "indexer" | "srch" => Some(Self::Indexer), + "teams" => Some(Self::Teams), + "native_triggers" => Some(Self::NativeTriggers), + "triggers_history" => Some(Self::TriggersHistory), + "git_sync" | "github_app" => Some(Self::GitSync), + "capture" => Some(Self::Capture), + "drafts" => Some(Self::Drafts), + "favorites" => Some(Self::Favorites), + "inputs" => Some(Self::Inputs), + "job_helpers" => Some(Self::JobHelpers), + "concurrency_groups" => Some(Self::ConcurrencyGroups), + "oidc" => Some(Self::Oidc), + "openapi" => Some(Self::Openapi), + "acls" => Some(Self::Acls), + "raw_apps" => Some(Self::RawApps), + "agent_workers" => Some(Self::AgentWorkers), + "mcp" => Some(Self::Mcp), + "docs" => Some(Self::Docs), + _ => None, + } + } +} + +/// Available scope actions +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScopeAction { + Read, // GET operations, list, view + Write, // POST, PUT, PATCH, DELETE operations, create, update, delete + Run, // Special action for running (scripts, flows, etc.) + Cancel, // Cancelling jobs (`CANCEL_PATH_ACTIONS`); covered by `jobs:write` +} + +impl ScopeAction { + pub fn as_str(&self) -> &'static str { + match self { + Self::Read => "read", + Self::Write => "write", + Self::Run => "run", + Self::Cancel => "cancel", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "read" => Some(Self::Read), + "write" => Some(Self::Write), + "delete" => Some(Self::Write), + "run" => Some(Self::Run), + "cancel" => Some(Self::Cancel), + _ => None, + } + } + + /// Check if this action includes another action + /// Write includes Read + pub fn includes(&self, other: &ScopeAction) -> bool { + match (self, other) { + (ScopeAction::Write, ScopeAction::Read) => true, + (ScopeAction::Run, ScopeAction::Read) => true, + (ScopeAction::Write, ScopeAction::Cancel) => true, + (a, b) => a == b, + } + } +} + +/// Whether `caller` grants at least everything `requested` grants (directional +/// containment). +/// +/// This is intentionally NOT `ScopeDefinition::includes`: that method answers +/// "does this scope grant access to a required action" using OR semantics over +/// resources (any overlap counts, and a `*` on either side matches), which is +/// correct for access checks but unsafe for subset checks — it would let a +/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or +/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that +/// EVERY requested resource is covered by SOME caller resource. +pub fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { + if caller.domain != requested.domain { + return false; + } + + // write subsumes read; otherwise the action must match exactly. + match (caller.action.as_str(), requested.action.as_str()) { + (c, r) if c == r || (c == "write" && r == "read") => {} + // Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an + // app-editor token can mint the narrower run-only credential. + ("write", "run") if caller.domain == "apps" => {} + ("write", "cancel") if caller.domain == "jobs" => {} + _ => return false, + } + + if caller.domain == "jobs" && caller.action == "run" { + match (&caller.kind, &requested.kind) { + (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { + return false + } + // Caller pinned to a kind, but the request covers any kind. + (Some(_), None) => return false, + _ => {} + } + } + + match (&caller.resource, &requested.resource) { + // Caller is unrestricted on resources: covers everything. + (None, _) => true, + // Caller is resource-restricted but the request is not: broader, unless the + // caller lists `*` and so already spans every path. Kept in step with + // `ScopeDefinition::includes`, which accepts that same grant for a + // whole-collection read: what a token may exercise, it may also delegate. + (Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"), + (Some(caller_resources), Some(requested_resources)) => { + resource_set_contains(caller_resources, requested_resources) + } + } +} + +/// Every resource in `requested` must be covered by some resource in `caller`. +fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { + if caller.iter().any(|r| r == "*") { + return true; + } + requested + .iter() + .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) +} + +/// Directional: does the single caller resource pattern cover `requested`? +/// `caller` may be an exact path or a `/*` subtree wildcard; `requested` +/// may itself be a subtree wildcard, in which case the whole requested subtree +/// must fall within the caller's subtree. +fn resource_covers(caller: &str, requested: &str) -> bool { + if caller == requested { + return true; + } + let Some(prefix) = caller.strip_suffix("/*") else { + // An exact caller resource only covers itself (handled above). + return false; + }; + let requested_base = requested.strip_suffix("/*").unwrap_or(requested); + requested_base == prefix + || (requested_base.starts_with(prefix) + && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) +} + +/// Stands in for an empty set of job token scopes. A token with `scopes: Some([])` is +/// unrestricted (an empty list is read as "no scopes defined"), so a job whose effective +/// scopes are empty carries this entry instead: it does not parse, and an unparseable +/// entry marks a token as scoped while granting nothing. +pub const NO_API_ACCESS_SCOPE: &str = "no_api_access"; + +/// Validates and normalizes a script or flow `job_token_scopes` setting. `[]` is valid and +/// leaves the job only the runtime routes about itself. +pub fn validate_job_token_scopes(scopes: &[String]) -> Result> { + let mut normalized: Vec = Vec::with_capacity(scopes.len()); + for scope in scopes { + let scope = scope.trim(); + // MCP scopes follow the MCP runtime's own grammar, and a list of only + // `if_jobs:filter_tags` entries reads as unscoped: neither can cap a job token. + if scope.starts_with("mcp:") || scope.starts_with("if_jobs:") { + return Err(Error::BadRequest(format!( + "Job token scopes cannot include '{scope}'" + ))); + } + let parsed = ScopeDefinition::from_scope_string(scope)?; + if ScopeDomain::from_str(&parsed.domain).is_none() + || ScopeAction::from_str(&parsed.action).is_none() + { + return Err(Error::BadRequest(format!( + "Unknown job token scope '{scope}'" + ))); + } + if !normalized.iter().any(|s| s == scope) { + normalized.push(scope.to_string()); + } + } + Ok(normalized) +} + +/// The scopes a pushed job's token is restricted to: what `ceiling` (the scopes of the job +/// or token the push acts for) and `own` (the target's `job_token_scopes` setting) both +/// grant. `None` on both sides means unrestricted. +/// +/// Keeps every entry of either side contained by some entry of the other. Overlaps that +/// neither side contains whole (two different globs) are dropped, which can only narrow. +pub fn intersect_job_token_scopes( + ceiling: Option<&[String]>, + own: Option<&[String]>, +) -> Option> { + match (ceiling, own) { + (None, None) => None, + (Some(c), None) => Some(c.to_vec()), + (None, Some(o)) => Some(o.to_vec()), + (Some(c), Some(o)) => { + let parse = |scopes: &[String]| -> Vec> { + scopes + .iter() + .map(|s| ScopeDefinition::from_scope_string(s).ok()) + .collect() + }; + let (pc, po) = (parse(c), parse(o)); + let mut out: Vec = vec![]; + let mut keep_covered = + |raw: &[String], parsed: &[Option], by: &[Option]| { + for (s, p) in raw.iter().zip(parsed) { + let Some(p) = p else { continue }; + if by.iter().flatten().any(|b| scope_contains(b, p)) + && !out.iter().any(|x| x == s) + { + out.push(s.clone()); + } + } + }; + keep_covered(o, &po, &pc); + keep_covered(c, &pc, &po); + Some(out) + } + } +} + +/// Validates the `job_token_scopes` of every step and agent tool of a flow, returning whether +/// any of them sets one. +pub fn validate_flow_step_job_token_scopes(value: &crate::flows::FlowValue) -> Result { + let mut any = false; + let mut check = |module: &crate::flows::FlowModule| -> anyhow::Result<()> { + if let Some(scopes) = &module.job_token_scopes { + any = true; + validate_job_token_scopes(scopes) + .map_err(|e| anyhow::anyhow!("step {}: {e}", module.id))?; + } + Ok(()) + }; + let extra: Vec = value + .failure_module + .iter() + .chain(value.preprocessor_module.iter()) + .map(|m| (**m).clone()) + .collect(); + crate::flows::FlowModule::traverse_modules(&value.modules, &mut check) + .and_then(|()| crate::flows::FlowModule::traverse_modules(&extra, &mut check)) + .map_err(|e| Error::BadRequest(e.to_string()))?; + Ok(any) +} + +/// Counts a deploy of a script or flow that restricts its job token, keyed by which shape +/// of restriction it picked, so take-up of the presets can be told from custom lists. +pub fn log_job_token_scopes_deploy(kind: &'static str, scopes: Option<&[String]>) { + let Some(scopes) = scopes else { return }; + let shape = match scopes { + [] => "no_api", + [only] if only == "oidc:write" => "oidc_only", + _ => "custom", + }; + crate::feature_usage::log_feature_usage( + "job_token_scopes", + "deploy", + &format!("{kind}:{shape}"), + ); +} + +/// The cap a flow step or agent tool's own `job_token_scopes` puts on its jobs. A definition +/// can reach the worker without the deploy-time validation (a raw flow), so a setting that +/// does not validate restricts the step to no API access rather than being trusted as is. +pub fn step_job_token_scopes(scopes: Option<&[String]>) -> Option> { + scopes.map(|s| validate_job_token_scopes(s).unwrap_or_default()) +} + +/// The `scopes` claim of a job token minted from the job's effective scopes. +pub fn job_token_jwt_scopes(effective: Option>) -> Option> { + match effective { + Some(s) if s.is_empty() => Some(vec![NO_API_ACCESS_SCOPE.to_string()]), + s => s, + } +} + +#[cfg(test)] +mod job_token_scopes_tests { + use super::*; + + fn v(s: &[&str]) -> Vec { + s.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn intersect_never_widens() { + assert_eq!(intersect_job_token_scopes(None, None), None); + let oidc = v(&["oidc:write"]); + assert_eq!(intersect_job_token_scopes(Some(&oidc), None), Some(oidc.clone())); + assert_eq!(intersect_job_token_scopes(None, Some(&oidc)), Some(oidc.clone())); + // A child setting asking for more than its parent holds gets only the common part. + assert_eq!( + intersect_job_token_scopes( + Some(&oidc), + Some(&v(&["oidc:write", "variables:read"])) + ), + Some(oidc.clone()) + ); + // Narrower entries win from either side. + assert_eq!( + intersect_job_token_scopes( + Some(&v(&["variables:write:f/a/*"])), + Some(&v(&["variables:read:f/a/b", "scripts:read"])) + ), + Some(v(&["variables:read:f/a/b"])) + ); + assert_eq!( + intersect_job_token_scopes(Some(&v(&["jobs:run"])), Some(&v(&["oidc:write"]))), + Some(vec![]) + ); + // The empty-set marker grants nothing to intersect with. + assert_eq!( + intersect_job_token_scopes(Some(&v(&[NO_API_ACCESS_SCOPE])), Some(&oidc)), + Some(vec![]) + ); + } + + #[test] + fn empty_effective_scopes_mint_a_scoped_token() { + assert_eq!( + job_token_jwt_scopes(Some(vec![])), + Some(v(&[NO_API_ACCESS_SCOPE])) + ); + assert_eq!(job_token_jwt_scopes(None), None); + } + + #[test] + fn validate_rejects_unusable_scopes() { + assert!(validate_job_token_scopes(&v(&["mcp:all"])).is_err()); + assert!(validate_job_token_scopes(&v(&["if_jobs:filter_tags:a"])).is_err()); + assert!(validate_job_token_scopes(&v(&["nope:write"])).is_err()); + assert!(validate_job_token_scopes(&v(&["*"])).is_err()); + assert_eq!( + validate_job_token_scopes(&v(&["oidc:write", " oidc:write"])).unwrap(), + v(&["oidc:write"]) + ); + // A step setting that skipped deploy validation restricts rather than widens. + assert_eq!( + step_job_token_scopes(Some(&v(&["if_jobs:filter_tags:bun"]))), + Some(vec![]) + ); + } +} diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 49d82db787..87148f3f4c 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -164,6 +164,7 @@ async fn prefetch_cached_script_inner( assets: script.assets, modules: script.modules, labels: script.labels, + job_token_scopes: script.job_token_scopes, inherited_labels: script.inherited_labels, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( @@ -463,6 +464,7 @@ pub async fn deploy_relocked_version( modules, auto_parent: None, labels: s.labels, + job_token_scopes: Some(s.job_token_scopes.clone()), skip_draft_deletion: None, }; @@ -483,7 +485,7 @@ pub async fn deploy_relocked_version( dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, \ - lock_error_logs, created_at) + job_token_scopes, lock_error_logs, created_at) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, \ @@ -491,7 +493,7 @@ pub async fn deploy_relocked_version( dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, \ - $6::text, clock_timestamp() + job_token_scopes, $6::text, clock_timestamp() FROM script WHERE hash = $2 AND workspace_id = $3; ", new_hash, s.hash.0, w_id, lock, modules_json, lock_error_logs).execute(&mut **tx).await?; diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 98a26aac25..e68b81e4de 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -811,7 +811,7 @@ fn format_pull_query(peek: String) -> String { j.same_worker, j.pre_run_error, j.visible_to_owner, j.tag, j.concurrent_limit, j.concurrency_time_window_s, j.flow_innermost_root_job, j.root_job, j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, j.priority, j.raw_code, j.raw_lock, j.raw_flow, - j.script_entrypoint_override, j.preprocessed, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path, + j.script_entrypoint_override, j.preprocessed, p.job_token_scopes, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path, COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin, p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email FROM q, j diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index d5cfd8e5a4..48e151736a 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -210,6 +210,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( None, None, None, + None, ) .await?; diff --git a/backend/windmill-queue/src/asset_dispatch.rs b/backend/windmill-queue/src/asset_dispatch.rs index a3f7154cda..b44541dc9d 100644 --- a/backend/windmill-queue/src/asset_dispatch.rs +++ b/backend/windmill-queue/src/asset_dispatch.rs @@ -822,6 +822,7 @@ async fn push_subscriber( debouncing_settings, concurrency_settings, labels: script.labels, + job_token_scopes: script.job_token_scopes, } }; @@ -904,6 +905,7 @@ async fn push_subscriber( JobTriggerKind::Asset, )), None, + None, ) .await .map_err(|e| error::Error::internal_err(format!("push asset-triggered job: {e:#}")))?; diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index a06c98b902..e48ff19713 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -74,7 +74,7 @@ use windmill_common::{ add_virtual_items_if_necessary, FlowModule, FlowModuleValue, FlowValue, InputTransform, Retry, StopAfterIf, }, - jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, QueuedJob, RawCode}, + jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, RawCode}, min_version::{MIN_VERSION_IS_AT_LEAST_1_432, MIN_VERSION_IS_AT_LEAST_1_440}, schedule::Schedule, scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, @@ -503,6 +503,7 @@ pub async fn push_init_job<'c>( None, None, None, + None, ) .await?; inner_tx.commit().await?; @@ -563,6 +564,7 @@ pub async fn push_periodic_bash_job<'c>( None, None, None, + None, ) .await?; inner_tx.commit().await?; @@ -725,9 +727,11 @@ async fn restart_perpetual_runs_at_path( PerpetualRunToRestart, "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, \ j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, \ - j.args AS \"args: sqlx::types::Json>>\" \ + j.args AS \"args: sqlx::types::Json>>\", \ + p.job_token_scopes AS \"job_token_scopes?\" \ FROM v2_job_queue q JOIN v2_job j USING (id) \ JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id \ + LEFT JOIN job_perms p ON p.job_id = q.id \ WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' \ AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL \ AND s.restart_unless_cancelled", @@ -765,6 +769,20 @@ async fn restart_perpetual_runs_at_path( Ok(true) } +/// A worker older than `job_token_scopes` ignores a step's or agent tool's own restriction, so +/// a flow that sets one is refused while such a worker is live, whichever way the flow arrived +/// (a deploy, a restart, a preview, a standalone agent, an eval). Free when every worker is +/// current. +async fn refuse_step_scopes_on_outdated_workers(value: &FlowValue) -> Result<(), Error> { + let gate = &windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES; + if !gate.met().await + && windmill_common::scopes::validate_flow_step_job_token_scopes(value).unwrap_or(true) + { + gate.assert().await?; + } + Ok(()) +} + /// A run of an earlier version at the path, and what its replacement inherits from it. struct PerpetualRunToRestart { id: Uuid, @@ -777,6 +795,8 @@ struct PerpetualRunToRestart { /// completion swaps in what a preprocessor returned. preprocessed: Option, args: Option>>>, + /// Its effective token scopes: the replacement never holds a wider token. + job_token_scopes: Option>, } /// What every run at the path moves to. @@ -857,6 +877,7 @@ async fn restart_perpetual_run( // an earlier version the next pass picks up. return Ok(()); } + let scope_ceiling = run.job_token_scopes; let (_, tx) = push( db, PushIsolationLevel::Transaction(tx), @@ -887,6 +908,7 @@ async fn restart_perpetual_run( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -1251,7 +1273,7 @@ lazy_static::lazy_static! { pub static ref MAX_RESULT_SIZE_MB: usize = std::env::var("MAX_RESULT_SIZE_MB").unwrap_or("500".to_string()).parse().unwrap_or(500); // Cache for perpetual-restart settings (restart_unless_cancelled, timeout) - keyed by (hash, workspace_id) - static ref RESTART_UNLESS_CANCELLED_CACHE: Cache<(i64, String), (bool, Option)> = Cache::new(10000); + static ref RESTART_UNLESS_CANCELLED_CACHE: Cache<(i64, String), (bool, Option, Option>)> = Cache::new(10000); // Cache for workspace error handler settings with 60s TTL // Key: workspace_id, Value: (error_handler, error_handler_extra_args, error_handler_muted_on_cancel, error_handler_muted_on_user_path, report_to_instance_alerts, expiry_timestamp) @@ -2037,13 +2059,13 @@ async fn restart_job_if_perpetual_inner( ) -> Result<(), Error> { let cache_key = (hash.0, queued_job.workspace_id.clone()); - let (restart, script_timeout) = if let Some(cached) = + let (restart, script_timeout, script_job_token_scopes) = if let Some(cached) = RESTART_UNLESS_CANCELLED_CACHE.get(&cache_key) { cached } else { let row = sqlx::query!( - "SELECT restart_unless_cancelled, timeout FROM script WHERE hash = $1 AND workspace_id = $2", + "SELECT restart_unless_cancelled, timeout, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", hash.0, &queued_job.workspace_id ) @@ -2054,10 +2076,12 @@ async fn restart_job_if_perpetual_inner( .as_ref() .and_then(|r| r.restart_unless_cancelled) .unwrap_or(false); - let script_timeout = row.and_then(|r| r.timeout); + let script_timeout = row.as_ref().and_then(|r| r.timeout); + let script_job_token_scopes = row.and_then(|r| r.job_token_scopes); - RESTART_UNLESS_CANCELLED_CACHE.insert(cache_key, (restart, script_timeout)); - (restart, script_timeout) + let cached = (restart, script_timeout, script_job_token_scopes); + RESTART_UNLESS_CANCELLED_CACHE.insert(cache_key, cached.clone()); + cached }; if restart { @@ -2103,6 +2127,8 @@ async fn restart_job_if_perpetual_inner( .await? .flatten() .unwrap_or_default(); + // The replacement never holds a wider token than the run it replaces. + let scope_ceiling = queued_job.job_token_scopes.clone(); let (_uuid, tx) = push( db, tx, @@ -2127,6 +2153,7 @@ async fn restart_job_if_perpetual_inner( // TODO(debouncing): handle properly debouncing_settings: DebouncingSettings::default(), labels: None, // labels already set on original job + job_token_scopes: script_job_token_scopes, }, PushArgs::from(&args.0), &queued_job.created_by, @@ -2153,6 +2180,7 @@ async fn restart_job_if_perpetual_inner( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -2411,6 +2439,8 @@ pub async fn maybe_enqueue_native_script_retry( ) .await?; let tx = PushIsolationLevel::IsolatedRoot(db.clone()); + // The retry never holds a wider token than the attempt it replaces. + let scope_ceiling = job.job_token_scopes.clone(); let (new_id, mut tx) = match push( db, tx, @@ -2459,6 +2489,7 @@ pub async fn maybe_enqueue_native_script_retry( None, trigger, None, + scope_ceiling.as_deref(), ) .await { @@ -3424,6 +3455,7 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>( None, None, None, + None, ) .await?; tx.commit().await?; @@ -3513,6 +3545,7 @@ pub async fn push_success_handler<'a, 'c, T: Serialize + Send + Sync>( None, None, None, + None, ) .await?; tx.commit().await?; @@ -3571,6 +3604,11 @@ pub struct MiniPulledJob { pub visible_to_owner: bool, pub permissioned_as_end_user_email: Option, pub runnable_settings_handle: Option, + /// The job's effective token scopes (`job_perms.job_token_scopes`), minted into its token. + /// No `sqlx(default)`: a query that forgets the column must fail rather than mint an + /// unrestricted token. + #[serde(default)] + pub job_token_scopes: Option>, } impl MiniPulledJob { @@ -3622,6 +3660,7 @@ impl MiniPulledJob { runnable_settings_handle: None, concurrent_limit: None, concurrency_time_window_s: None, + job_token_scopes: None, } } } @@ -3659,6 +3698,10 @@ pub struct MiniCompletedJob { /// the server would reject every completion it sends, not just build jobs. #[serde(default)] pub build_binary_only: bool, + /// The job's effective token scopes, carried from the pull: a re-run (retry, perpetual + /// restart) caps itself with them after the job's `job_perms` row may have been swept. + #[serde(default)] + pub job_token_scopes: Option>, } impl From for MiniCompletedJob { @@ -3686,9 +3729,11 @@ impl From for MiniCompletedJob { cache_ttl: job.cache_ttl, cache_ignore_s3_path: job.cache_ignore_s3_path, runnable_settings_handle: job.runnable_settings_handle, - // `QueuedJobV2` carries no args, and nothing reaches the restart gate - // through this conversion — the worker completes jobs from the pulled job. + // `QueuedJobV2` carries no args, which is what marks a binary-build job. build_binary_only: false, + // Nor scopes: a caller whose completion can re-run the job (the monitor's zombie + // recovery) fills them in from `job_perms` while the job is still queued. + job_token_scopes: None, } } } @@ -3719,6 +3764,7 @@ impl From for MiniCompletedJob { cache_ttl: job.cache_ttl, cache_ignore_s3_path: job.cache_ignore_s3_path, runnable_settings_handle: job.runnable_settings_handle, + job_token_scopes: job.job_token_scopes.clone(), build_binary_only: crate::binary_prebuild::is_build_binary_job( job.args.as_ref().map(|x| &x.0), ), @@ -3751,6 +3797,7 @@ impl From> for MiniCompletedJob { cache_ttl: job.cache_ttl, cache_ignore_s3_path: job.cache_ignore_s3_path, runnable_settings_handle: job.runnable_settings_handle, + job_token_scopes: job.job_token_scopes.clone(), build_binary_only: crate::binary_prebuild::is_build_binary_job( job.args.as_ref().map(|x| &x.0), ), @@ -3825,48 +3872,6 @@ impl MiniPulledJob { .and_then(|f| f.chat_input_enabled) } - pub fn from(job: &QueuedJob) -> MiniPulledJob { - MiniPulledJob { - workspace_id: job.workspace_id.clone(), - id: job.id, - args: job.args.clone(), - parent_job: job.parent_job.clone(), - created_by: job.created_by.clone(), - started_at: job.started_at.clone(), - scheduled_for: job.scheduled_for, - runnable_path: job.script_path.clone(), - kind: job.job_kind, - runnable_id: job.script_hash.clone(), - canceled_reason: job.canceled_reason.clone(), - canceled_by: job.canceled_by.clone(), - permissioned_as: job.permissioned_as.clone(), - permissioned_as_email: job.email.clone(), - flow_status: job.flow_status.clone(), - tag: job.tag.clone(), - script_lang: job.language.clone(), - same_worker: job.same_worker, - pre_run_error: job.pre_run_error.clone(), - concurrent_limit: job.concurrent_limit.clone(), - concurrency_time_window_s: job.concurrency_time_window_s.clone(), - runnable_settings_handle: job.runnable_settings_handle, - flow_innermost_root_job: job.root_job.clone(), // QueuedJob is taken from v2_as_queue, where root_job corresponds to flow_innermost_root_job in v2_job - root_job: None, - timeout: job.timeout.clone(), - flow_step_id: job.flow_step_id.clone(), - cache_ttl: job.cache_ttl.clone(), - cache_ignore_s3_path: job.cache_ignore_s3_path.clone(), - priority: job.priority.clone(), - preprocessed: job.preprocessed.clone(), - script_entrypoint_override: job.script_entrypoint_override.clone(), - trigger: job.schedule_path.clone(), - trigger_kind: job - .schedule_path - .is_some() - .then(|| JobTriggerKind::Schedule.into()), - visible_to_owner: job.visible_to_owner.clone(), - permissioned_as_end_user_email: None, - } - } pub fn is_flow(&self) -> bool { self.kind.is_flow() } @@ -3968,6 +3973,7 @@ impl PulledJob { groups, folders, end_user_email: self.job.permissioned_as_end_user_email.clone(), + job_token_scopes: self.job.job_token_scopes.clone(), }), _ => None, }; @@ -4074,8 +4080,10 @@ pub async fn get_mini_pulled_job<'c>( trigger, trigger_kind as \"trigger_kind: TriggerKindLabel\", visible_to_owner, - NULL as permissioned_as_end_user_email - FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1", + NULL as permissioned_as_end_user_email, + job_perms.job_token_scopes + FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id + LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1", job_id, ) .fetch_optional(e) @@ -5767,8 +5775,10 @@ pub fn get_mini_completed_job<'a, 'e, A: sqlx::Acquire<'e, Database = Postgres> "SELECT j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as, j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, - COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\" + COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\", + p.job_token_scopes AS \"job_token_scopes?\" FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id + LEFT JOIN job_perms p ON p.job_id = j.id WHERE j.id = $1 AND j.workspace_id = $2", id, w_id @@ -6018,6 +6028,10 @@ pub async fn push<'c, 'd>( end_user_email: Option, trigger: Option, suspended_mode: Option, + // Caps the new job's token on top of the target's own `job_token_scopes`: the scopes of + // the job (or job token) this push acts for, `None` when nothing above it restricts it. + // A job may never hold a token wider than the job that created it. + scope_ceiling: Option<&[String]>, ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { Box::pin(push_inner( db, @@ -6049,6 +6063,7 @@ pub async fn push<'c, 'd>( end_user_email, trigger, suspended_mode, + scope_ceiling, )) .await } @@ -6084,6 +6099,7 @@ async fn push_inner<'c, 'd>( end_user_email: Option, trigger: Option, suspended_mode: Option, + scope_ceiling: Option<&[String]>, ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { // The worker builds a preview's `_MODULES` arg into the job as its module code. Every // caller-reachable value lands in `args` or `extra` (webhook query and headers go to @@ -6317,6 +6333,8 @@ async fn push_inner<'c, 'd>( debouncing_settings: DebouncingSettings, retry_settings: RetrySettings, labels: Option>, + /// The target's own `job_token_scopes` setting. + job_token_scopes: Option>, /// A `dependencies` job that only compiles an already-deployed script's binary. /// It shares the job kind, but not the queue policy lock generation needs. build_binary_only: bool, @@ -6339,6 +6357,7 @@ async fn push_inner<'c, 'd>( debouncing_settings, retry_settings, labels, + job_token_scopes, build_binary_only, } = match job_payload { JobPayload::ScriptHash { @@ -6353,6 +6372,7 @@ async fn push_inner<'c, 'd>( concurrency_settings, debouncing_settings, labels, + job_token_scopes, } => { if apply_preprocessor { preprocessed = Some(false); @@ -6370,6 +6390,7 @@ async fn push_inner<'c, 'd>( dedicated_worker, _low_level_priority: priority, labels, + job_token_scopes, ..Default::default() } } @@ -6585,6 +6606,7 @@ async fn push_inner<'c, 'd>( ..Default::default() }, JobPayload::RawFlow { mut value, path, restarted_from } => { + refuse_step_scopes_on_outdated_workers(&value).await?; add_virtual_items_if_necessary(&mut value.modules); let flow_status: FlowStatus = match restarted_from { @@ -6708,20 +6730,21 @@ async fn push_inner<'c, 'd>( // script's `dedicated_worker` (it drives the dedicated tag below), // but the SingleStepFlow payload doesn't — resolve it from the // script row so a dedicated-worker script keeps its dedicated pool. - let dedicated_worker = if let Some(h) = &hash { + // The script's `job_token_scopes` is resolved the same way. + let (dedicated_worker, job_token_scopes) = if let Some(h) = &hash { // Read on the non-RLS pool: push_inner is also entered with RLS // isolation variants under which the script row may be invisible, // which would mis-resolve dedicated_worker routing. - sqlx::query_scalar::<_, Option>( - "SELECT dedicated_worker FROM script WHERE hash = $1 AND workspace_id = $2", + sqlx::query_as::<_, (Option, Option>)>( + "SELECT dedicated_worker, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", ) .bind(h.0) .bind(workspace_id) .fetch_optional(db) .await? - .flatten() + .unwrap_or((None, None)) } else { - None + (None, None) }; break 'ssf JobPayloadUntagged { runnable_id: hash.map(|h| h.0), @@ -6733,6 +6756,7 @@ async fn push_inner<'c, 'd>( }, language, dedicated_worker, + job_token_scopes, concurrency_settings, debouncing_settings, retry_settings: retry.as_ref().map(RetrySettings::from).unwrap_or_default(), @@ -6903,7 +6927,14 @@ async fn push_inner<'c, 'd>( ..Default::default() } } - JobPayload::Flow { path, dedicated_worker, apply_preprocessor, version, labels } => { + JobPayload::Flow { + path, + dedicated_worker, + apply_preprocessor, + version, + labels, + job_token_scopes, + } => { let mut ntx = tx.into_tx().await?; // Do not use the lite version unless all workers are updated. let data = if *DISABLE_FLOW_SCRIPT @@ -6922,6 +6953,7 @@ async fn push_inner<'c, 'd>( tx = PushIsolationLevel::Transaction(ntx); let mut value = data.value().clone(); + refuse_step_scopes_on_outdated_workers(&value).await?; let priority = value.priority; let cache_ttl = value.cache_ttl.map(|x| x as i32); let cache_ignore_s3_path = value.cache_ignore_s3_path; @@ -6970,6 +7002,7 @@ async fn push_inner<'c, 'd>( concurrency_settings, debouncing_settings, labels, + job_token_scopes, ..Default::default() } } @@ -7034,7 +7067,21 @@ async fn push_inner<'c, 'd>( memory_id: None, no_inherited_flow_env: false, }; + // The completed job's own scopes are gone with its `job_perms` row, so the restart + // takes the flow's current setting (and the restarting caller's ceiling). + let job_token_scopes = match &flow_path { + Some(flow_path) => sqlx::query_scalar::<_, Option>>( + "SELECT job_token_scopes FROM flow WHERE path = $1 AND workspace_id = $2", + ) + .bind(flow_path) + .bind(workspace_id) + .fetch_optional(db) + .await? + .flatten(), + None => None, + }; let value = flow_data.value(); + refuse_step_scopes_on_outdated_workers(value).await?; let priority = value.priority; let concurrency_settings = value.concurrency_settings.clone(); let debouncing_settings = value.debouncing_settings.clone(); @@ -7058,6 +7105,7 @@ async fn push_inner<'c, 'd>( _low_level_priority: priority, concurrency_settings, debouncing_settings, + job_token_scopes, ..Default::default() } } @@ -7177,7 +7225,14 @@ async fn push_inner<'c, 'd>( .map(|e| (Some(e.0), e.1)) .unwrap_or_else(|| (None, None)); - let tag = if dedicated_worker.is_some_and(|x| x) { + let job_token_scopes = windmill_common::scopes::intersect_job_token_scopes( + scope_ceiling, + job_token_scopes.as_deref(), + ); + + // A dedicated worker runs every job it serves with its own unscoped worker token, so a + // job with a restricted token runs on the regular workers of its language instead. + let tag = if dedicated_worker.is_some_and(|x| x) && job_token_scopes.is_none() { let flow_prefix = if job_kind == JobKind::Flow || job_kind == JobKind::FlowDependencies { "flow/" } else { @@ -7505,9 +7560,9 @@ async fn push_inner<'c, 'd>( INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null) ), inserted_job_perms AS ( - INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email) - values ($1, $32, $33, $34, $35, $36, $37, $2, $41) - ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email + INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email, job_token_scopes) + values ($1, $32, $33, $34, $35, $36, $37, $2, $41, $47) + ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email, job_token_scopes = EXCLUDED.job_token_scopes ) INSERT INTO v2_job_queue (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle) @@ -7562,6 +7617,7 @@ async fn push_inner<'c, 'd>( labels.as_deref() as Option<&[String]>, runnable_path, workspace_id, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .warn_after_seconds(1) @@ -8525,6 +8581,7 @@ pub async fn get_same_worker_job( v2_job.trigger, v2_job.trigger_kind, v2_job.visible_to_owner, + p.job_token_scopes, v2_job.raw_code, v2_job.raw_lock, v2_job.raw_flow, diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 97b175fa9e..81be3cb7e8 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -103,6 +103,7 @@ async fn get_schedule_metadata<'c>( on_behalf_of, _runnable_settings_handle, _labels, + _job_token_scopes, ) = windmill_common::get_latest_hash_for_path( &mut **tx, db, @@ -368,7 +369,8 @@ pub async fn push_scheduled_job<'c>( .warn_after_seconds_with_sql(1, "get_flow_version_info_from_version".to_string()) .await?; let on_behalf_of = flow_info.on_behalf_of(&schedule.workspace_id, db).await?; - let FlowVersionInfo { version, tag, dedicated_worker, labels, .. } = flow_info; + let FlowVersionInfo { version, tag, dedicated_worker, labels, job_token_scopes, .. } = + flow_info; ( JobPayload::Flow { @@ -377,6 +379,7 @@ pub async fn push_scheduled_job<'c>( apply_preprocessor: false, version, labels, + job_token_scopes, }, tag, None, @@ -442,6 +445,7 @@ pub async fn push_scheduled_job<'c>( on_behalf_of, runnable_settings_handle, labels, + job_token_scopes, ) = windmill_common::get_latest_hash_for_path( &mut *tx, db, @@ -535,6 +539,7 @@ pub async fn push_scheduled_job<'c>( concurrency_time_window_s, ), labels, + job_token_scopes, }, if schedule.tag.as_ref().is_some_and(|x| x != "") { schedule.tag.clone() @@ -664,6 +669,7 @@ pub async fn push_scheduled_job<'c>( JobTriggerKind::Schedule, )), None, + None, ) .warn_after_seconds_with_sql(1, "push in push_scheduled_job".to_string()) .await?; diff --git a/backend/windmill-queue/tests/debounce_test.rs b/backend/windmill-queue/tests/debounce_test.rs index d74f20ff3f..14f7ec2dd9 100644 --- a/backend/windmill-queue/tests/debounce_test.rs +++ b/backend/windmill-queue/tests/debounce_test.rs @@ -3458,6 +3458,7 @@ mod debounce { visible_to_owner: false, permissioned_as_end_user_email: None, runnable_settings_handle: rs_handle, + job_token_scopes: None, }; let pulled = PulledJob { @@ -3699,6 +3700,7 @@ mod debounce { visible_to_owner: false, permissioned_as_end_user_email: None, runnable_settings_handle: rs_handle, + job_token_scopes: None, }; let pulled = PulledJob { diff --git a/backend/windmill-queue/tests/native_retry_test.rs b/backend/windmill-queue/tests/native_retry_test.rs index 3b2290a50f..9b0e05a74c 100644 --- a/backend/windmill-queue/tests/native_retry_test.rs +++ b/backend/windmill-queue/tests/native_retry_test.rs @@ -45,6 +45,7 @@ mod native_retry { cache_ignore_s3_path: None, runnable_settings_handle: handle, build_binary_only: false, + job_token_scopes: None, } } @@ -165,6 +166,39 @@ mod native_retry { Ok(()) } + // A retry keeps the restriction of the run it replaces even once that run's `job_perms` + // row is gone (swept after it left the queue): the completed job carries its scopes. + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn retry_keeps_the_restriction_of_a_swept_run(db: Pool) -> anyhow::Result<()> { + let retry = Retry { + constant: ConstantDelay { attempts: 1, seconds: 1 }, + exponential: Default::default(), + retry_if: None, + }; + let handle = insert_rs( + RunnableSettings { + debouncing_settings: None, + concurrency_settings: None, + retry_settings: RetrySettings::from(&retry).insert_cached(&db).await?, + }, + &db, + ) + .await?; + let root_id = Uuid::new_v4(); + let mut root = mini(root_id, None, handle); + root.job_token_scopes = Some(vec!["jobs:run".to_string()]); + + assert!(maybe_enqueue_native_script_retry(&db, &root, &None, &no_result).await?); + let (r1_id, ..) = retry_by_attempt(&db, root_id, 1).await.expect("retry 1 exists"); + let scopes: Option> = + sqlx::query_scalar("SELECT job_token_scopes FROM job_perms WHERE job_id = $1") + .bind(r1_id) + .fetch_one(&db) + .await?; + assert_eq!(scopes, Some(vec!["jobs:run".to_string()])); + Ok(()) + } + // Cancellation always wins over a pending retry. #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] async fn canceled_job_does_not_retry(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index c68c59bb16..ed50215473 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -93,6 +93,7 @@ mod schedule_push { cache_ignore_s3_path: None, runnable_settings_handle: None, build_binary_only: false, + job_token_scopes: None, } } diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 773616a807..f05fff66f8 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -249,6 +249,7 @@ impl RunJob { None, None, None, + None, ) .await .expect("push has to succeed"); @@ -296,6 +297,7 @@ impl RunJob { None, None, None, + None, ) .await .expect("push has to succeed"); @@ -969,6 +971,7 @@ pub async fn run_deployed_relative_imports( debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .push(&db2) .await; diff --git a/backend/windmill-trigger/src/trigger_helpers.rs b/backend/windmill-trigger/src/trigger_helpers.rs index 7e47613d1a..ae805d195e 100644 --- a/backend/windmill-trigger/src/trigger_helpers.rs +++ b/backend/windmill-trigger/src/trigger_helpers.rs @@ -976,6 +976,7 @@ async fn trigger_script_with_retry_and_error_handler<'c>( None, Some(trigger), suspended_mode, + None, ) .await?; diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index a2fc7fd58d..209390004a 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -48,6 +48,10 @@ pub struct Flow { pub on_behalf_of: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Caps the scopes of the token minted for each job of this flow; `None` = unrestricted. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, /// Labels inherited from the parent folder, computed at read time. Not stored on the flow row. #[sqlx(default)] #[serde(default, skip_serializing_if = "Option::is_none")] @@ -145,6 +149,15 @@ pub struct NewFlow { pub ws_error_handler_muted: Option, #[serde(default)] pub labels: Option>, + /// Absent keeps the deployed flow's value, so a client unaware of the setting cannot + /// drop a restriction by saving; `null` clears it. + #[sqlx(skip)] + #[serde( + default, + deserialize_with = "crate::more_serde::double_option", + skip_serializing_if = "Option::is_none" + )] + pub job_token_scopes: Option>>, /// Caller-intent flag (set by the CLI / git sync): when true, deploying /// this flow must NOT delete an existing user draft at the same path. /// Transient — never persisted. @@ -183,6 +196,12 @@ pub struct EditFlow { pub ws_error_handler_muted: Option, #[serde(default)] pub labels: Option>, + #[serde( + default, + deserialize_with = "crate::more_serde::double_option", + skip_serializing_if = "Option::is_none" + )] + pub job_token_scopes: Option>>, #[serde(default)] pub skip_draft_deletion: Option, } @@ -207,6 +226,7 @@ impl EditFlow { preserve_on_behalf_of: self.preserve_on_behalf_of, ws_error_handler_muted: self.ws_error_handler_muted, labels: self.labels, + job_token_scopes: self.job_token_scopes, skip_draft_deletion: self.skip_draft_deletion, } } @@ -615,6 +635,9 @@ pub struct FlowModule { pub pass_flow_input_directly: Option, #[serde(skip_serializing_if = "Option::is_none")] pub debouncing: Option, + /// Caps the token of the jobs this step runs, on top of the flow's own restriction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -902,6 +925,9 @@ pub struct AgentTool { /// Overrides the description auto-derived from the underlying runnable. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Caps the token of this tool's jobs, on top of the agent step's own restriction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, pub value: ToolValue, } @@ -915,6 +941,7 @@ impl AgentTool { self.id = flow_module.id; self.summary = flow_module.summary; + self.job_token_scopes = flow_module.job_token_scopes; self.value = ToolValue::FlowModule(module_value); } } @@ -927,6 +954,7 @@ impl From<&AgentTool> for Option { id: tool.id.clone(), value: to_raw_value(module_value), summary: tool.summary.clone(), + job_token_scopes: tool.job_token_scopes.clone(), ..Default::default() }), ToolValue::Mcp(_) => None, @@ -1331,6 +1359,7 @@ pub fn add_virtual_items_if_necessary(modules: &mut Vec) { apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, + job_token_scopes: None, }); } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 530b8a948d..9768373b91 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -515,6 +515,8 @@ pub enum JobPayload { concurrency_settings: ConcurrencySettings, debouncing_settings: DebouncingSettings, labels: Option>, + /// The script's `job_token_scopes` setting, read with the rest of the payload. + job_token_scopes: Option>, }, FlowNode { id: FlowNodeId, @@ -579,6 +581,8 @@ pub enum JobPayload { apply_preprocessor: bool, version: i64, labels: Option>, + /// The flow's `job_token_scopes` setting, read with the rest of the payload. + job_token_scopes: Option>, }, RestartedFlow { completed_job_id: Uuid, diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 9e29731505..1e4a8858ed 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -331,7 +331,7 @@ pub const SCRIPT_COLUMNS: &str = concat!( "timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ", "visible_to_runner_only, auto_kind, codebase, has_preprocessor, ", "on_behalf_of, ", - "assets, modules, labels, concurrency_key, concurrent_limit, ", + "assets, modules, labels, job_token_scopes, concurrency_key, concurrent_limit, ", "concurrency_time_window_s, debounce_key, debounce_delay_s, runnable_settings_handle", ); @@ -401,6 +401,9 @@ pub struct Script { pub modules: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// Caps the scopes of the token minted for each job of this script; `None` = unrestricted. + #[serde(skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, /// Labels inherited from the parent folder, computed at read time. Not stored on the script row. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -580,6 +583,14 @@ pub struct NewScript { pub auto_parent: Option, #[serde(default)] pub labels: Option>, + /// Absent keeps the parent version's value, so a client unaware of the setting + /// cannot drop a restriction by redeploying; `null` clears it. + #[serde( + default, + deserialize_with = "crate::more_serde::double_option", + skip_serializing_if = "Option::is_none" + )] + pub job_token_scopes: Option>>, /// Caller-intent flag (set by the CLI / git sync): when true, deploying /// this script must NOT delete an existing user draft at the same path. /// Transient — never persisted. Deliberately excluded from `impl Hash` @@ -628,6 +639,7 @@ impl Hash for NewScript { self.preserve_on_behalf_of.hash(state); self.assets.hash(state); self.labels.hash(state); + self.job_token_scopes.hash(state); if let Some(modules) = &self.modules { let mut sorted: Vec<_> = modules.iter().collect(); sorted.sort_by_key(|(k, _)| *k); diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 930a329be4..9e50ddd6c0 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -599,6 +599,16 @@ async fn enqueue_windmill_tool( None, None, None, + // A tool never holds a wider token than the agent calling it, and its own setting + // narrows that further. + windmill_common::scopes::intersect_job_token_scopes( + ctx.job.job_token_scopes.as_deref(), + windmill_common::scopes::step_job_token_scopes( + tool_module.job_token_scopes.as_deref(), + ) + .as_deref(), + ) + .as_deref(), ) .await?; diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 958caeaefc..08b561358e 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -2745,6 +2745,7 @@ mod tests { let mut its = HashMap::new(); its.insert(key.to_string(), js(expr)); AgentTool { + job_token_scopes: None, id: id.to_string(), summary: None, description: None, @@ -2772,6 +2773,7 @@ mod tests { script_tool("a", "x", "authoring_flow_expr"), script_tool("b", "y", "keep_me"), AgentTool { + job_token_scopes: None, id: "m".to_string(), summary: None, description: None, @@ -2819,6 +2821,7 @@ mod tests { fn narrow_roster_keeps_the_entries_a_run_named() { fn named(id: &str, summary: &str) -> AgentTool { AgentTool { + job_token_scopes: None, id: id.to_string(), summary: Some(summary.to_string()), description: None, @@ -2834,6 +2837,7 @@ mod tests { } fn mcp(id: &str, summary: &str, path: &str) -> AgentTool { AgentTool { + job_token_scopes: None, id: id.to_string(), summary: Some(summary.to_string()), description: None, @@ -2846,6 +2850,7 @@ mod tests { } fn websearch(id: &str, summary: Option<&str>) -> AgentTool { AgentTool { + job_token_scopes: None, id: id.to_string(), summary: summary.map(str::to_string), description: None, diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index c4a792c6a0..bb47d3e4be 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2896,6 +2896,9 @@ pub async fn handle_wac_v2_output( concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + // Capped by the parent at push, which already holds this + // script's setting. + job_token_scopes: None, }) } else { Err(error::Error::internal_err( @@ -3023,6 +3026,7 @@ pub async fn handle_wac_v2_output( apply_preprocessor: false, version: flow_info.version, labels: flow_info.labels.clone(), + job_token_scopes: flow_info.job_token_scopes.clone(), }; let on_behalf_of = flow_info.on_behalf_of(&job.workspace_id, db).await?; (ChildRunnable::Deployed(payload), on_behalf_of) @@ -3272,6 +3276,7 @@ pub async fn handle_wac_v2_output( None, // end_user_email None, // trigger None, // suspended_mode + job.job_token_scopes.as_deref(), ) .await?; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 9c012fd117..cd8923d408 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -2309,6 +2309,7 @@ mod tests { visible_to_owner: false, permissioned_as_end_user_email: None, runnable_settings_handle: None, + job_token_scopes: None, } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 6b51073b84..b88d3341d3 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -16,8 +16,6 @@ use tokio::time::timeout; // Re-export proxy env-var snapshots so callers (including EE modules) // can keep importing them via `crate::{NO_PROXY, HTTP_PROXY, HTTPS_PROXY}`. use windmill_common::client::AuthedClient; -use windmill_common::db::UserDbWithAuthed; -use windmill_common::get_latest_deployed_hash_for_path; use windmill_common::jobs::InlineScriptTarget; use windmill_common::jobs::RunInlineScriptFnParams; use windmill_common::jobs::WorkerInternalServerInlineUtils; @@ -3595,8 +3593,12 @@ pub async fn run_worker( // dispatch by path and return before that check, so a job sent down them // would run with whatever arguments survived the failure. let fails_before_running = job.pre_run_error.is_some(); + // A dedicated worker or flow runner runs every job it gets with its own + // unscoped worker token, so a job with a restricted token runs here, with the + // token minted for it, whichever tag brought it. + let restricted = job.job_token_scopes.is_some(); - if !dedicated_workers.is_empty() && !fails_before_running { + if !dedicated_workers.is_empty() && !fails_before_running && !restricted { let dedicated_worker_tx = job.runnable_path.as_ref().and_then(|path| { // For flow steps inside branches/loops, runnable_path includes // nesting segments (e.g. f/flow/branchone-0/a) but the dedicated @@ -3641,7 +3643,9 @@ pub async fn run_worker( NextJob::Http(_) => None, }; - if let Some(flow_runners) = flow_runners.filter(|_| !fails_before_running) { + if let Some(flow_runners) = + flow_runners.filter(|_| !fails_before_running && !restricted) + { let key_o = job.flow_step_id.as_ref().map(|x| x.to_string()); if let Some(key) = key_o { if let Some(flow_runner_tx) = flow_runners.runners.get(&key) { @@ -7234,30 +7238,8 @@ pub fn init_worker_internal_server_inline_utils( run_inline_script: Arc::new(|params: RunInlineScriptFnParams| { Box::pin(async move { let (script_hash, runnable_path) = match params.target { - InlineScriptTarget::Path(ref path) => { - let db = params - .conn - .as_sql() - .ok_or_else(|| { - error::Error::InternalErr( - "run_inline_script by path requires a SQL connection" - .to_string(), - ) - })? - .clone(); - let authed_ref = params.user_db.as_ref().map(|(_, a)| a.to_authed_ref()); - let user_db_authed = - params.user_db.as_ref().zip(authed_ref.as_ref()).map( - |((udb, _), ar)| UserDbWithAuthed { db: udb.clone(), authed: ar }, - ); - let script_hash_info = get_latest_deployed_hash_for_path( - user_db_authed, - db, - ¶ms.workspace_id, - path, - ) - .await?; - (ScriptHash(script_hash_info.hash), Some(path.clone())) + InlineScriptTarget::Path { ref path, hash } => { + (ScriptHash(hash), Some(path.clone())) } InlineScriptTarget::Hash(hash) => (ScriptHash(hash), None), }; diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 7c1ea23de4..445b9af720 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2366,6 +2366,7 @@ async fn advance_flow_status( THEN v2_job_status.flow_leaf_jobs ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id + LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1 RETURNING v2_job_queue.workspace_id, @@ -2402,7 +2403,8 @@ async fn advance_flow_status( v2_job.trigger, v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\", v2_job.visible_to_owner, - NULL as permissioned_as_end_user_email", + NULL as permissioned_as_end_user_email, + job_perms.job_token_scopes", flow, &step_path as &[&str], step, @@ -4327,11 +4329,15 @@ async fn push_next_flow_job( }; // only start runners if we're not already in a squash for loop + // Runners would run its steps with their own unscoped token: a restricted flow, or a loop + // that restricts itself or any step in it, runs them as regular jobs. let start_runners = flow_runners.is_none() + && flow_job.job_token_scopes.is_none() && matches!( next_status, NextStatus::NextLoopIteration { start_runners: true, .. } - ); + ) + && !restricts_any_step(module); let do_not_pass_runners = matches!(next_status, NextStatus::NextStep { .. }) && flow_runners @@ -4746,6 +4752,16 @@ async fn push_next_flow_job( end_user_email, None, None, + // A step never holds a wider token than the flow running it, and its own setting + // narrows that further. + windmill_common::scopes::intersect_job_token_scopes( + flow_job.job_token_scopes.as_deref(), + windmill_common::scopes::step_job_token_scopes( + module.job_token_scopes.as_deref(), + ) + .as_deref(), + ) + .as_deref(), ) .warn_after_seconds(2) .await?; @@ -6330,6 +6346,16 @@ pub fn raw_script_to_payload( } } +/// Whether `module`, or any step or agent tool under it, sets `job_token_scopes`. +fn restricts_any_step(module: &FlowModule) -> bool { + let mut any = false; + let _ = FlowModule::traverse_modules(&vec![module.clone()], &mut |m: &FlowModule| { + any |= m.job_token_scopes.is_some(); + Ok(()) + }); + any +} + async fn flow_to_payload( path: String, delete_after_use: bool, @@ -6339,13 +6365,14 @@ async fn flow_to_payload( ) -> Result { let flow_info = get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?; let on_behalf_of = flow_info.on_behalf_of(w_id, &db).await?; - let FlowVersionInfo { version, tag, .. } = flow_info; + let FlowVersionInfo { version, tag, job_token_scopes, .. } = flow_info; let payload = JobPayload::Flow { path, dedicated_worker: None, apply_preprocessor: false, version, labels: None, + job_token_scopes, }; Ok(JobPayloadWithTag { payload, @@ -6419,6 +6446,7 @@ pub async fn script_to_payload( delete_after_use, delete_after_secs, timeout, + job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings }, .. @@ -6436,6 +6464,7 @@ pub async fn script_to_payload( priority, apply_preprocessor: apply_preprocessor.unwrap_or(false), labels: None, + job_token_scopes, }, tag_override.to_owned().or(tag), delete_after_use, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 861a9625e9..b608601991 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -137,6 +137,7 @@ async fn maybe_queue_binary_prebuild( None, None, None, + None, ) .await?; tx.commit().await?; diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 6a346b8251..a266834f41 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -52,6 +52,7 @@ export interface FlowFile { schema?: any; on_behalf_of_email?: string; has_on_behalf_of?: boolean; + job_token_scopes?: string[] | null; // Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no // perms are set. The CLI applies diffs through /acls/add and /acls/remove // (see applyExtraPermsDiff) — never through update_flow — so a perm-only @@ -273,12 +274,22 @@ export async function pushFlow( // so a perm-only edit never bumps the flow version. Strip the field from the // body that goes to update_flow / create_flow and treat it as a separate // step both for the up-to-date short-circuit and after the deploy. - const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & { + const { + extra_perms: localPerms, + job_token_scopes: localJobTokenScopes, + ...localFlowBody + } = localFlow as FlowFile & { extra_perms?: Record; }; + // Always sent, unlike the other settings: the server keeps a restriction the body omits, + // so a flow.yaml without the key has to clear it explicitly. + const jobTokenScopes = localJobTokenScopes ?? null; if (flow) { - if (isSuperset(localFlowBody, flow)) { + if ( + isSuperset(localFlowBody, flow) && + JSON.stringify(jobTokenScopes) === JSON.stringify(flow.job_token_scopes ?? null) + ) { log.info(colors.green(`Flow ${remotePath} is up to date`)); } else { log.info(colors.bold.yellow(`Updating flow ${remotePath}...`)); @@ -289,6 +300,7 @@ export async function pushFlow( path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlowBody, + job_token_scopes: jobTokenScopes, ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, @@ -304,6 +316,7 @@ export async function pushFlow( path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlowBody, + job_token_scopes: jobTokenScopes, ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 06db3db1bf..4938b6aeb3 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -636,6 +636,9 @@ export async function handleFile( envs: typed?.envs, modules: modules, labels: typed?.labels, + // Always sent: the server keeps a restriction the body omits, so a script.yaml without + // the key has to clear it explicitly. + job_token_scopes: typed?.job_token_scopes ?? null, }; const hasOnBehalfOf = (typed as any)?.has_on_behalf_of ?? !!typed?.on_behalf_of_email; @@ -712,6 +715,7 @@ export async function handleFile( (hasOnBehalfOf ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) && deepEqual(typed.envs, remote.envs) && deepEqual(typed.labels ?? null, remote.labels ?? null) && + deepEqual(typed.job_token_scopes ?? null, remote.job_token_scopes ?? null) && deepEqual(modules ?? null, remote.modules ?? null)) ) { log.info(colors.green(`Script ${remotePath} is up to date`)); diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index d087448dad..feca594fed 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6187,7 +6187,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} `, "raw-app": `--- name: raw-app diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index 0d70e231cf..71447c6b1f 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -458,6 +458,8 @@ export async function deployItem( path, requestBody: { ...flow, + // Sent even when unset: the backend keeps a restriction an omitted field leaves out. + job_token_scopes: flow.job_token_scopes ?? null, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, // Usernames are per-workspace, so the source's principal names nobody in @@ -471,6 +473,8 @@ export async function deployItem( workspace: workspaceTo, requestBody: { ...flow, + // Sent even when unset: the backend keeps a restriction an omitted field leaves out. + job_token_scopes: flow.job_token_scopes ?? null, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, // Usernames are per-workspace, so the source's principal names nobody in @@ -498,6 +502,7 @@ export async function deployItem( requestBody: { ...script, lock: script.lock, + job_token_scopes: script.job_token_scopes ?? null, parent_hash: parentHash, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, @@ -889,6 +894,7 @@ export async function getItemValue( summary: flow.summary, description: flow.description, value: flow.value, + job_token_scopes: flow.job_token_scopes ?? null, }; } else if (kind === "script") { const script = await provider.getScriptByPath({ workspace, path }); @@ -898,6 +904,7 @@ export async function getItemValue( schema: script.schema, summary: script.summary, language: script.language, + job_token_scopes: script.job_token_scopes ?? null, }; } else if (kind === "app" || kind === "raw_app") { return await provider.getAppByPath({ workspace, path }); diff --git a/docs/auth-surface.md b/docs/auth-surface.md index 15f2538cc5..38e5cefbf6 100644 --- a/docs/auth-surface.md +++ b/docs/auth-surface.md @@ -52,6 +52,29 @@ Symbols, not line numbers, are cited: they drift less. with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play for every reader until turned off. The resource ACL is the only control, so granting write on a folder grants that influence. +- **Restricted job tokens** (`job_token_scopes` on scripts and flows): a job's effective scopes + are stored on its `job_perms` row at push and minted into its token + (`create_token_for_owner`); an empty set is minted as `NO_API_ACCESS_SCOPE`, since + `scopes: Some([])` reads as unscoped. Every `push` takes a `scope_ceiling`, and the job gets the + ceiling ∩ the target's own setting: flow steps take their flow job's, agent tools their agent's, + WAC children their parent's, retries the run they replace, and API pushes the calling job's + (`caller_scope_ceiling`) — a scoped user or webhook token caps nothing. The row is swept once a + job leaves the queue, so a restart of a completed flow takes the flow's current setting. Schedules, + triggers and error/success handlers start from the target's own setting. A restricted token + keeps only the runtime routes about its own job (`is_own_job_runtime_route`) and the reads of + its own flow run (`flow_run_read_route_job`, checked against its lineage: the orchestrator + evaluates a step's `results.x` with the token of the step that just finished). That lineage is + trusted, so a restricted job token, even an admin's, can only place a job it starts in its own + run (`unclaimable_run_lineage`); a route that + authenticates a token itself instead of through the route layer must call + `check_job_token_scope`. A restricted job never runs on a dedicated worker, which uses its own + unscoped token for every job. `$var:`/`$res:` args are resolved through the API with the job's + own token, so they need read scopes. A deploy that omits the setting keeps the deployed value + and `null` clears it, so an unaware client cannot drop a restriction; setting one is refused + until every worker supports it, since an older worker mints without it. The setting belongs to + the deployed version: an older script hash run by hash runs with that version's setting (a + restricted caller still caps it). Write scopes on scripts, flows, schedules or triggers let a + job escape its restriction. - **A remote deploy token is a credential for another instance**, held per account and workspace (`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`). Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 289dfa21d4..7d92e08484 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -618,7 +618,9 @@ on_behalf_of: flow.on_behalf_of, preserve_on_behalf_of: $preserveOnBehalfOf || undefined, deployment_message: deploymentMsg || undefined, - labels: (flow as any).labels + labels: (flow as any).labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: flow.job_token_scopes ?? null } }) // Gated for operators without `manage_triggers`, who cannot have captures to move. @@ -670,7 +672,9 @@ on_behalf_of: flow.on_behalf_of, preserve_on_behalf_of: $preserveOnBehalfOf || undefined, deployment_message: deploymentMsg || undefined, - labels: (flow as any).labels + labels: (flow as any).labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: flow.job_token_scopes ?? null } }) } diff --git a/frontend/src/lib/components/JobTokenScopesSetting.svelte b/frontend/src/lib/components/JobTokenScopesSetting.svelte new file mode 100644 index 0000000000..6ef1ecf3c4 --- /dev/null +++ b/frontend/src/lib/components/JobTokenScopesSetting.svelte @@ -0,0 +1,89 @@ + + +
+ { + if (value != null) { + value = null + } else { + setMode('oidc_only') + } + }} + options={{ + right: 'Restrict the job token', + rightTooltip: + "Limit what the WM_TOKEN of this " + + kind + + "'s jobs can do through the API, e.g. only mint an OIDC token. Use it for code that handles untrusted input, such as AI agents." + }} + /> + {#if value != null} + setMode(v)}> + {#snippet children({ item })} + + + + {/snippet} + + {#if mode === 'custom'} + value ?? [], (scopes) => (value = [...scopes])} + emptyLabel="No scopes selected. The job token can only reach its own job." + /> + {/if} +

+ {#if kind === 'step'} + The WM_TOKEN of every job this step runs can only do what these scopes allow, on top + of the flow's own restriction. Loop and branch bodies, and AI agent tools, inherit it. + {:else} + The WM_TOKEN of every job of this {kind} can only do what these scopes allow. Jobs + it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction. + {/if} + Variables and resources passed as $var:/$res: inputs, an AI agent's provider + resource, relative imports and object storage each need a read scope, and script state + (getState/setState) needs resources:write. Write scopes on scripts, + flows, schedules or triggers let a job escape the restriction. Jobs with a restricted token never + run on dedicated workers. +

+ {/if} +
diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 05d0f68496..62f9851fcc 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -83,6 +83,7 @@ import { fade } from 'svelte/transition' import Popover from './Popover.svelte' import Toggle from './Toggle.svelte' + import JobTokenScopesSetting from './JobTokenScopesSetting.svelte' import ScriptSchema from './ScriptSchema.svelte' import Section from './Section.svelte' import Label from './Label.svelte' @@ -787,7 +788,9 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined, assets: script.assets, modules: script.modules, - labels: script.labels + labels: script.labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: script.job_token_scopes ?? null } }) @@ -2065,6 +2068,9 @@ />
+
+ +
{/if} + +
+ +
diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index d607da6ac1..079647112c 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -5,6 +5,7 @@ import Path from '$lib/components/Path.svelte' import FlowCard from '../common/FlowCard.svelte' import Toggle from '$lib/components/Toggle.svelte' + import JobTokenScopesSetting from '$lib/components/JobTokenScopesSetting.svelte' import { Alert, Button, SecondsInput } from '$lib/components/common' import { getContext } from 'svelte' import type { FlowEditorContext } from '../types' @@ -101,6 +102,7 @@ }, { name: 'Error Handler Muted', active: Boolean(flowStore.val.ws_error_handler_muted) }, { name: 'Invisible to Others', active: Boolean(flowStore.val.visible_to_runner_only) }, + { name: 'Restricted Job Token', active: flowStore.val.job_token_scopes != null }, { name: 'Shared Directory', active: Boolean(flowStore.val.value.same_worker) }, { name: 'Preserve Step Tags', active: Boolean(flowStore.val.value.preserve_step_tags) }, { name: 'Cache Results', active: Boolean(flowStore.val.value.cache_ttl) }, @@ -724,6 +726,13 @@ type="flow" /> {/if} + + diff --git a/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte b/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte index 9879b2899b..cd9d7647cf 100644 --- a/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte +++ b/frontend/src/lib/components/flows/header/FlowYamlEditor.svelte @@ -86,6 +86,9 @@ if (parsed['on_behalf_of'] !== undefined) { flowStore.val.on_behalf_of = parsed['on_behalf_of'] } + if (parsed['job_token_scopes'] !== undefined) { + flowStore.val.job_token_scopes = parsed['job_token_scopes'] + } flowStore.val.value = parsed.value flowStore.val.schema = parsed.schema flowStore.val.tag = parsed.tag diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index 40e9ddc952..eff88e6920 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -41,6 +41,7 @@ export type ExtendedOpenFlow = OpenFlow & { visible_to_runner_only?: boolean on_behalf_of_email?: string on_behalf_of?: string + job_token_scopes?: string[] | null } export type FlowInputEditorState = { diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index 2b479abceb..7104deb382 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -107,6 +107,9 @@ export function filteredContentForExport(flow: ExtendedOpenFlow) { if (flow.tag) { o['tag'] = flow.tag } + if (flow.job_token_scopes != null) { + o['job_token_scopes'] = flow.job_token_scopes + } return o } @@ -119,6 +122,7 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & { visible_to_runner_only?: boolean on_behalf_of_email?: string on_behalf_of?: string + job_token_scopes?: string[] | null } { const newFlow: Flow = $state.snapshot(flow) diff --git a/frontend/src/lib/components/scripts/WacExportDrawer.svelte b/frontend/src/lib/components/scripts/WacExportDrawer.svelte index 59f1dca1b0..ee2c402216 100644 --- a/frontend/src/lib/components/scripts/WacExportDrawer.svelte +++ b/frontend/src/lib/components/scripts/WacExportDrawer.svelte @@ -46,6 +46,7 @@ if (script.visible_to_runner_only) exportData.visible_to_runner_only = script.visible_to_runner_only if (script.auto_kind) exportData.auto_kind = script.auto_kind if (script.has_preprocessor) exportData.has_preprocessor = script.has_preprocessor + if (script.job_token_scopes != null) exportData.job_token_scopes = script.job_token_scopes if (script.modules && Object.keys(script.modules).length > 0) exportData.modules = script.modules scriptData = exportData diff --git a/frontend/src/lib/components/settings/ScopeSelector.svelte b/frontend/src/lib/components/settings/ScopeSelector.svelte index 24c9b99562..b3293ab36e 100644 --- a/frontend/src/lib/components/settings/ScopeSelector.svelte +++ b/frontend/src/lib/components/settings/ScopeSelector.svelte @@ -15,6 +15,8 @@ class?: string /** Renders above the scope-list card, below the Selected Scopes summary. */ topSlot?: Snippet + /** What an empty selection means, shown when no scope is selected. */ + emptyLabel?: string } interface ScopeState { @@ -38,7 +40,8 @@ selectedScopes = $bindable([]), disabled = false, class: className = '', - topSlot + topSlot, + emptyLabel = 'No scopes selected. Token will have full access.' }: Props = $props() let scopeDomains = $state(null) @@ -532,7 +535,7 @@ {#if selectedScopes.length === 0} -

No scopes selected. Token will have full access.

+

{emptyLabel}

{:else if hasAdministratorScope}

Administrator scope grants full access to all resources.

' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index c659bcab12..27dc888291 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3347,7 +3347,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 29f79a2ca5..a6a654c863 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -589,4 +589,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} From 013b302d484b967443cbf747fb9f2ecbb1145033 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 3 Oct 2026 09:34:10 +0200 Subject: [PATCH 017/174] perf: keep a pg executor connection per database (#11492) * fix: stop the pg executor cache from pinning a pooler slot Co-Authored-By: Claude Opus 5.5 (1M context) * test: retry while the evicted pg backend exits Co-Authored-By: Claude Opus 5.5 (1M context) * perf: keep a pg executor connection per database Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KHjj2iG8kKPCaFuK6bqXvi * fix: close a pg connection whose query was cut off by a timeout or cancel Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KHjj2iG8kKPCaFuK6bqXvi * fix: keep a pg connection only after its query read every result Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KHjj2iG8kKPCaFuK6bqXvi * fix: free idle pg connections and retry when a connect is refused Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KHjj2iG8kKPCaFuK6bqXvi * fix: retry a pg connect only when a connection cap refused it Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KHjj2iG8kKPCaFuK6bqXvi * fix: detect pg connection caps by SQLSTATE and label cloud connections as new Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KHjj2iG8kKPCaFuK6bqXvi --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/worker.rs | 73 +++- backend/windmill-worker/src/pg_executor.rs | 470 ++++++++++++--------- 2 files changed, 335 insertions(+), 208 deletions(-) diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 938763f21e..6966b40e1f 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -1758,8 +1758,9 @@ async fn test_postgresql_cached_connection_released_for_other_key( let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); - // Two cache keys for the same role: only the sslmode differs. - let run = |sslmode: &str| { + // Keys for the same role: one pool scope when only the sslmode differs, two + // when the database does, which the role's limit still counts together. + let run = |dbname: &str, sslmode: &str| { RunJob::from(JobPayload::Code(RawCode { hash: None, content: "SELECT 1 as n;".into(), @@ -1778,7 +1779,7 @@ async fn test_postgresql_cached_connection_released_for_other_key( })) .arg( "database", - json!({"host": "localhost", "port": 5432, "dbname": "windmill", + json!({"host": "localhost", "port": 5432, "dbname": dbname, "user": "wm_pg_cache_one_conn", "password": "changeme", "sslmode": sslmode}), ) .run_until_complete(&db, false, port) @@ -1787,16 +1788,21 @@ async fn test_postgresql_cached_connection_released_for_other_key( // The evicted connection's backend exits asynchronously, so a fresh // connection can briefly still count it. Retrying absorbs that; without the // eviction the cached connection stays open for 60s and every retry fails. - for sslmode in ["disable", "prefer", "disable"] { + for (dbname, sslmode) in [ + ("windmill", "disable"), + ("windmill", "prefer"), + ("postgres", "disable"), + ("windmill", "disable"), + ] { let mut result = json!(null); for _ in 0..5 { - result = run(sslmode).await.json_result().unwrap(); + result = run(dbname, sslmode).await.json_result().unwrap(); if result == json!([{"n": 1}]) { break; } tokio::time::sleep(std::time::Duration::from_millis(200)).await; } - assert_eq!(result, json!([{"n": 1}]), "sslmode={sslmode}"); + assert_eq!(result, json!([{"n": 1}]), "{dbname} sslmode={sslmode}"); } clear_pg_cache().await; @@ -1869,6 +1875,61 @@ async fn test_postgresql_open_transaction_not_reused(db: Pool) -> anyh Ok(()) } +/// A worker alternating between two databases keeps a connection for each. +#[sqlx::test(fixtures("base"))] +#[serial(pg_cache)] +async fn test_postgresql_cache_keeps_a_connection_per_database( + db: Pool, +) -> anyhow::Result<()> { + use std::sync::atomic::Ordering; + use windmill_worker::pg_executor::{clear_pg_cache, CACHE_HITS}; + + initialize_tracing().await; + clear_pg_cache().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let run = |dbname: &str| { + RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: "SELECT current_database() as d;".into(), + path: None, + lock: None, + language: ScriptLang::Postgresql, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + tag: None, + })) + .arg( + "database", + json!({"host": "localhost", "port": 5432, "dbname": dbname, + "user": "postgres", "password": "changeme"}), + ) + .run_until_complete(&db, false, port) + }; + + let hits_before = CACHE_HITS.load(Ordering::Relaxed); + for dbname in ["windmill", "postgres", "windmill", "postgres"] { + let result = run(dbname).await.json_result().unwrap(); + assert_eq!(result, json!([{"d": dbname}])); + } + let hits = CACHE_HITS.load(Ordering::Relaxed) - hits_before; + assert_eq!( + hits, 2, + "the last two jobs should reuse a cached connection" + ); + + clear_pg_cache().await; + Ok(()) +} + /// Runs multiple PG jobs through a SINGLE worker (like production) to verify /// that SET ROLE / search_path changes do not leak across jobs. #[sqlx::test(fixtures("base"))] diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 3222d7ca3e..b13cfa4535 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -1,7 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::Context; use base64::{engine, Engine as _}; @@ -13,7 +12,6 @@ use rust_decimal::{prelude::FromPrimitive, Decimal}; use serde_json::value::RawValue; use serde_json::Map; use serde_json::Value; -use tokio::sync::RwLock; use tokio_postgres::Client; use tokio_postgres::{types::ToSql, Row}; use tokio_postgres::{ @@ -54,11 +52,249 @@ use windmill_common::client::AuthedClient; use windmill_types::s3::S3Object; lazy_static! { - pub static ref CONNECTION_COUNTER: Arc>> = - Arc::new(RwLock::new(HashMap::new())); pub static ref CACHE_HITS: AtomicU64 = AtomicU64::new(0); } +/// Idle connections kept per cache key, checked out by a job for the duration +/// of its query and checked back in afterwards. +/// +/// Invariant: the process never keeps an idle connection in a pool scope +/// ([`pg_pool_scope`]) while one of its jobs runs on a connection of its own in +/// that scope. Behind a session-mode pooler (PgBouncer, RDS Proxy pinning, …) +/// an idle connection holds a server slot, and the job on its own connection +/// waits for that slot with no timeout, forever if other jobs keep reusing the +/// idle connection. +static PG_CONNECTION_CACHE: std::sync::Mutex = + std::sync::Mutex::new(PgConnectionCache { idle: Vec::new(), own: Vec::new(), next_own_id: 0 }); +static PG_CONNECTION_CACHE_SWEEPER: std::sync::Once = std::sync::Once::new(); +const PG_CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(60); +/// Across all keys. Native mode runs 8 jobs at once in one process. +const PG_MAX_IDLE_CONNECTIONS: usize = 8; +/// A pooler can also share slots across pool scopes: two host names for one +/// pooler, or a per-database or per-user limit. A job on its own connection for +/// longer than this may be waiting on such a slot, so no idle connection is +/// kept, in any scope, until it finishes. +const PG_OWN_CONNECTION_STALL: Duration = Duration::from_secs(5); + +struct PgConnectionCache { + /// Least recently used first. + idle: Vec, + /// Jobs of this process running on a connection of their own. + own: Vec, + next_own_id: u64, +} + +struct OwnConnection { + id: u64, + scope: String, + since: Instant, +} + +impl PgConnectionCache { + /// Registers a job on its own connection. Returns the idle connections of + /// its scope, to be closed before it connects. + fn register_own(&mut self, scope: &str) -> (u64, Vec) { + let id = self.next_own_id; + self.next_own_id += 1; + self.own + .push(OwnConnection { id, scope: scope.to_string(), since: Instant::now() }); + let (evicted, kept) = std::mem::take(&mut self.idle) + .into_iter() + .partition(|c| c.scope == scope); + self.idle = kept; + (id, evicted) + } + + fn stalled(&self) -> bool { + self.own + .iter() + .any(|o| o.since.elapsed() >= PG_OWN_CONNECTION_STALL) + } + + fn may_keep(&self, scope: &str) -> bool { + !self.stalled() && self.own.iter().all(|o| o.scope != scope) + } +} + +fn pg_connection_cache() -> std::sync::MutexGuard<'static, PgConnectionCache> { + PG_CONNECTION_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +/// What a session pooler shares server slots across: PgBouncer pools by +/// database and user. Keys differing only in sslmode, TLS inputs, auth mode or +/// options share a scope. Hosts are compared by name, not address. +fn pg_pool_scope(database: &PgDatabase) -> String { + format!( + "{:?}", + ( + database.host.trim().to_ascii_lowercase(), + database.port.unwrap_or(5432), + &database.dbname, + database.login_name().trim(), + ) + ) +} + +struct PgConnection { + key: String, + scope: String, + client: Client, + connection_task: tokio::task::JoinHandle<()>, + last_used: Instant, +} + +impl Drop for PgConnection { + fn drop(&mut self) { + self.connection_task.abort(); + } +} + +/// A job's claim on a connection. Dropping it checks the connection back in, +/// or closes it when its query did not finish or keeping it would break the +/// cache invariant. +struct PgConnectionLease { + scope: String, + /// Set while the job is on a connection of its own. + own: Option, + conn: Option, + /// Check the connection back in on drop. Set once the job's query has read + /// all its results, so every other way out closes the connection. + keep: bool, +} + +impl PgConnectionLease { + /// Takes the most recently used idle connection for `key`. Otherwise + /// registers the job as on its own connection and closes the idle + /// connections of its scope, so they cannot hold the server slot its own + /// connection is about to need. + fn checkout(key: &str, scope: String) -> Self { + let (lease, evicted) = { + let mut cache = pg_connection_cache(); + match cache.idle.iter().rposition(|c| c.key == key) { + Some(i) => { + let conn = cache.idle.remove(i); + ( + Self { scope, own: None, conn: Some(conn), keep: false }, + vec![], + ) + } + None => { + let (id, evicted) = cache.register_own(&scope); + ( + Self { scope, own: Some(id), conn: None, keep: false }, + evicted, + ) + } + } + }; + drop(evicted); + lease + } + + fn uncached() -> Self { + Self { scope: String::new(), own: None, conn: None, keep: false } + } + + /// Gives up a cached connection that failed its probe; the job then + /// connects on its own. + fn discard(&mut self) { + let conn = self.conn.take(); + let evicted = if self.own.is_none() { + let (id, evicted) = pg_connection_cache().register_own(&self.scope); + self.own = Some(id); + evicted + } else { + vec![] + }; + drop(conn); + drop(evicted); + } + + fn client(&self) -> &Client { + &self.conn.as_ref().expect("lease holds a connection").client + } +} + +impl Drop for PgConnectionLease { + fn drop(&mut self) { + let conn = self.conn.take(); + let (kept, closed) = { + let mut cache = pg_connection_cache(); + if let Some(id) = self.own { + cache.own.retain(|o| o.id != id); + } + match conn { + Some(mut conn) if self.keep && cache.may_keep(&self.scope) => { + conn.last_used = Instant::now(); + cache.idle.push(conn); + let over = cache.idle.len().saturating_sub(PG_MAX_IDLE_CONNECTIONS); + (true, cache.idle.drain(..over).collect::>()) + } + conn => (false, conn.into_iter().collect()), + } + }; + drop(closed); + if kept { + PG_CONNECTION_CACHE_SWEEPER.call_once(|| { + tokio::spawn(sweep_idle_pg_connections()); + }); + } + } +} + +async fn sweep_idle_pg_connections() { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let (stalled, closed) = { + let mut cache = pg_connection_cache(); + let stalled = cache.stalled(); + let (closed, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut cache.idle) + .into_iter() + .partition(|c| stalled || c.last_used.elapsed() > PG_CONNECTION_IDLE_TIMEOUT); + cache.idle = kept; + (stalled, closed) + }; + if !closed.is_empty() { + if stalled { + tracing::info!( + "Closing {} cached pg executor connection(s): a job has been on its own connection for over {}s", + closed.len(), + PG_OWN_CONNECTION_STALL.as_secs() + ); + } else { + tracing::info!( + "Closing {} cached pg executor connection(s) due to inactivity", + closed.len() + ); + } + } + } +} + +/// PostgreSQL's role, database and server caps, by SQLSTATE 53300 since the +/// server translates the message under a non-English `lc_messages`, and +/// PgBouncer's client and user caps, whose messages are English only and come +/// with a generic code. +fn is_connection_cap_refusal(e: &Error) -> bool { + if let Error::Anyhow { error, .. } = e { + let postgres_cap = error + .chain() + .filter_map(|cause| cause.downcast_ref::()) + .any(|pg| pg.code() == Some(&tokio_postgres::error::SqlState::TOO_MANY_CONNECTIONS)); + if postgres_cap { + return true; + } + } + e.to_string().contains("no more connections allowed") +} + +fn close_idle_pg_connections() -> usize { + let idle = std::mem::take(&mut pg_connection_cache().idle); + idle.len() +} + /// How far a job's statements have got, read by its stall warning. struct PgProgress { start: std::time::Instant, @@ -149,173 +385,8 @@ async fn warn_on_stalled_statement( } } -/// One reusable connection per worker process, checked out by a job for the -/// duration of its query and checked back in afterwards. -/// -/// Invariant: the process never keeps an idle connection while one of its jobs -/// runs on a connection of its own. Behind a session-mode pooler (PgBouncer, -/// RDS Proxy pinning, …) the idle connection holds a server slot, and the job -/// on its own connection waits for that slot with no timeout, forever if the -/// idle connection keeps being reused by other jobs. -static PG_CONNECTION_CACHE: std::sync::Mutex = - std::sync::Mutex::new(PgConnectionCache { idle: None, checked_out: false, uncached: 0 }); -static PG_CONNECTION_CACHE_SWEEPER: std::sync::Once = std::sync::Once::new(); -const PG_CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(60); - -struct PgConnectionCache { - idle: Option, - /// The cached connection is out with a running job. - checked_out: bool, - /// Jobs of this process running on a connection that is not the cached one. - uncached: usize, -} - -fn pg_connection_cache() -> std::sync::MutexGuard<'static, PgConnectionCache> { - PG_CONNECTION_CACHE - .lock() - .unwrap_or_else(|e| e.into_inner()) -} - -struct PgConnection { - key: String, - client: Client, - connection_task: tokio::task::JoinHandle<()>, - last_used: std::time::Instant, -} - -impl Drop for PgConnection { - fn drop(&mut self) { - self.connection_task.abort(); - } -} - -#[derive(Clone, Copy, PartialEq)] -enum PgLeaseSlot { - CheckedOut, - Uncached, -} - -/// A job's claim on a connection. Dropping it checks the connection back in, -/// or closes it when keeping it would break the cache invariant. -struct PgConnectionLease { - slot: PgLeaseSlot, - conn: Option, - /// Cache this job's own connection on release. - cache_on_release: bool, -} - -impl PgConnectionLease { - /// Takes the cached connection when it matches `key`. Otherwise registers - /// the job as uncached and closes the idle connection, whatever its key, so - /// it cannot hold the server slot the job's own connection is about to need. - fn checkout(key: &str) -> Self { - let (lease, evicted) = { - let mut cache = pg_connection_cache(); - match cache.idle.take() { - Some(conn) if conn.key == key => { - cache.checked_out = true; - (Self::new(PgLeaseSlot::CheckedOut, Some(conn)), None) - } - other => { - cache.uncached += 1; - (Self::new(PgLeaseSlot::Uncached, None), other) - } - } - }; - drop(evicted); - lease - } - - fn uncached() -> Self { - pg_connection_cache().uncached += 1; - Self::new(PgLeaseSlot::Uncached, None) - } - - fn new(slot: PgLeaseSlot, conn: Option) -> Self { - Self { slot, conn, cache_on_release: false } - } - - /// Closes the lease's connection so it is never cached again: a cached one - /// that failed its probe (the job then connects on its own), or one a job - /// failed on or left inside a transaction. - fn discard(&mut self) { - let conn = self.conn.take(); - if self.slot == PgLeaseSlot::CheckedOut { - let mut cache = pg_connection_cache(); - cache.checked_out = false; - cache.uncached += 1; - self.slot = PgLeaseSlot::Uncached; - } - drop(conn); - } - - fn client(&self) -> &Client { - &self.conn.as_ref().expect("lease holds a connection").client - } -} - -impl Drop for PgConnectionLease { - fn drop(&mut self) { - let Some(mut conn) = self.conn.take() else { - if self.slot == PgLeaseSlot::Uncached { - pg_connection_cache().uncached -= 1; - } - return; - }; - let kept = { - let mut cache = pg_connection_cache(); - let wants_cache = match self.slot { - PgLeaseSlot::CheckedOut => { - cache.checked_out = false; - true - } - PgLeaseSlot::Uncached => { - cache.uncached -= 1; - self.cache_on_release - } - }; - if wants_cache && cache.uncached == 0 && !cache.checked_out && cache.idle.is_none() { - conn.last_used = std::time::Instant::now(); - cache.idle = Some(conn); - None - } else { - Some(conn) - } - }; - match kept { - None => PG_CONNECTION_CACHE_SWEEPER.call_once(|| { - tokio::spawn(sweep_idle_pg_connection()); - }), - Some(conn) => drop(conn), - } - } -} - -async fn sweep_idle_pg_connection() { - loop { - tokio::time::sleep(Duration::from_secs(5)).await; - let expired = { - let mut cache = pg_connection_cache(); - if cache - .idle - .as_ref() - .is_some_and(|c| c.last_used.elapsed() > PG_CONNECTION_IDLE_TIMEOUT) - { - cache.idle.take() - } else { - None - } - }; - if expired.is_some() { - tracing::info!("Closing cache pg executor connection due to inactivity"); - } - } -} - pub async fn clear_pg_cache() { - let idle = pg_connection_cache().idle.take(); - drop(idle); - CONNECTION_COUNTER.write().await.clear(); + close_idle_pg_connections(); } /// How the connection authenticates, which also keys the connection cache: a @@ -1125,11 +1196,11 @@ pub async fn do_postgresql( let connect_started = std::time::Instant::now(); let mut cached_connection_failed_reset = false; + let mut reused_cached_connection = false; let mut lease = if *CLOUD_HOSTED { PgConnectionLease::uncached() } else { - increment_connection_counter(&database_string).await; - PgConnectionLease::checkout(&database_string) + PgConnectionLease::checkout(&database_string, pg_pool_scope(&database)) }; if lease.conn.is_some() { @@ -1187,6 +1258,7 @@ pub async fn do_postgresql( { tracing::info!("Using cached connection"); CACHE_HITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + reused_cached_connection = true; } else { tracing::info!("Cached connection is stale, creating new one"); cached_connection_failed_reset = true; @@ -1195,16 +1267,29 @@ pub async fn do_postgresql( } if lease.conn.is_none() { let (client, connection_task) = - new_pg_connection(&database, auth_mode, conn.as_sql()).await?; + match new_pg_connection(&database, auth_mode, conn.as_sql()).await { + Ok(connected) => connected, + // A server or pooler can also refuse outright past a per-login or + // total connection cap, which pool scopes do not capture. Free the + // idle connections of every scope and try once more. + Err(e) => { + if !is_connection_cap_refusal(&e) || close_idle_pg_connections() == 0 { + return Err(e); + } + tokio::time::sleep(Duration::from_millis(250)).await; + new_pg_connection(&database, auth_mode, conn.as_sql()).await? + } + }; lease.conn = Some(PgConnection { key: database_string.clone(), + scope: lease.scope.clone(), client, connection_task, - last_used: std::time::Instant::now(), + last_used: Instant::now(), }); } - let fresh_connection = lease.slot == PgLeaseSlot::Uncached; + let fresh_connection = !reused_cached_connection; let log_progress = !run_inline && !annotations.prepare; let connect_time = connect_started.elapsed(); if log_progress && connect_time >= PG_SLOW_STEP { @@ -1381,17 +1466,7 @@ pub async fn do_postgresql( ) .await } - .map_err(|e| map_s3object_jsonb_overflow(e, had_s3object_input)); - // A failed job can leave the connection inside a transaction, or with its - // query still running after a timeout or cancel, and the status byte can - // lag behind an error. Never reuse it. - let result = match result { - Ok(result) => result, - Err(e) => { - lease.discard(); - return Err(e); - } - }; + .map_err(|e| map_s3object_jsonb_overflow(e, had_s3object_input))?; *mem_peak = size.load(Ordering::Relaxed) as i32; @@ -1400,7 +1475,6 @@ pub async fn do_postgresql( // back, as it always has without the cache. The status comes with the last // reply, so a script that ends cleanly pays nothing for this check. if lease.client().transaction_status() != tokio_postgres::TransactionStatus::Idle { - lease.discard(); if !run_inline { windmill_queue::append_logs( &job.id, @@ -1411,8 +1485,12 @@ pub async fn do_postgresql( ) .await; } - } else if !*CLOUD_HOSTED && lease.slot == PgLeaseSlot::Uncached { - lease.cache_on_release = is_most_used_conn(&database_string).await; + } else { + // Keep the connection only after a query that read its results to the end. + // An error may have stopped reading early (the result cap, a decode failure), + // as a timeout, a dropped future or a first-row collection do: the server + // keeps sending, and the next job's reset probe would wait on it. + lease.keep = !*CLOUD_HOSTED && !collection_strategy.collect_first_row_only(); } drop(lease); @@ -1421,18 +1499,6 @@ pub async fn do_postgresql( return Ok(result); } -async fn is_most_used_conn(database_string: &str) -> bool { - let counter_map = CONNECTION_COUNTER.read().await; - let current_count = counter_map.get(database_string).copied().unwrap_or(0); - let max_count = counter_map.values().copied().max().unwrap_or(0); - current_count >= max_count -} - -async fn increment_connection_counter(database_string: &str) { - let mut counter_map = CONNECTION_COUNTER.write().await; - *counter_map.entry(database_string.to_string()).or_insert(0) += 1; -} - /// For each `(s3object)` arg in `sig_args`: download the referenced file, decode it /// to JSON text, then rewrite the arg to bind as `jsonb`. Mutates `args_map` in place /// so the existing bind path picks up the materialized payload. Returns whether any From 0eb6320faa4a9811d21bfed256493deb0927c5d2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 3 Oct 2026 09:42:09 +0200 Subject: [PATCH 018/174] test: make the import refusal reach the handler guard (#11502) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/job_token_scopes.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/tests/job_token_scopes.rs b/backend/tests/job_token_scopes.rs index 8129fe2b3e..580dc1e8e2 100644 --- a/backend/tests/job_token_scopes.rs +++ b/backend/tests/job_token_scopes.rs @@ -163,7 +163,7 @@ async fn test_restricted_job_token_is_confined(db: Pool) -> anyhow::Re "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, job_token_scopes) VALUES ($1, 'test@windmill.dev', 'test-user', true, false, '{}', '{}', 'test-workspace', - '{jobs:run}')", + '{jobs:write,jobs:run}')", ) .bind(admin_job) .execute(&db) @@ -186,7 +186,8 @@ async fn test_restricted_job_token_is_confined(db: Pool) -> anyhow::Re .json(&json!([])) .send() .await?; - assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{}", resp.text().await?); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!(resp.text().await?.contains("cannot import")); // Nor can it, even an admin's, place a child in an unrelated run: the flow-run routes // trust that lineage. let resp = client From 64f9505688ee4b64a39c137505e173610d24b275 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Sat, 3 Oct 2026 09:42:21 +0200 Subject: [PATCH 019/174] feat: show upcoming events when hovering a schedule's cron expression (#11499) * feat: show upcoming events when hovering a schedule's cron expression Co-Authored-By: Claude Opus 5.5 (1M context) * fix: read cron version from the draft for draft-only schedule previews Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../schedules/ScheduleUpcomingEvents.svelte | 65 +++++++++++++++++++ .../components/schedules/SchedulesList.svelte | 15 ++++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/schedules/ScheduleUpcomingEvents.svelte diff --git a/frontend/src/lib/components/schedules/ScheduleUpcomingEvents.svelte b/frontend/src/lib/components/schedules/ScheduleUpcomingEvents.svelte new file mode 100644 index 0000000000..b239d1c310 --- /dev/null +++ b/frontend/src/lib/components/schedules/ScheduleUpcomingEvents.svelte @@ -0,0 +1,65 @@ + + +
+
Estimated upcoming events ({timezone})
+ {#if preview.error} + Could not compute upcoming events + {:else if preview.current === undefined} + + {:else} + {#each preview.current as date (date)} + {format(new Date(date))} + {:else} + No upcoming events + {/each} + {/if} +
diff --git a/frontend/src/lib/components/schedules/SchedulesList.svelte b/frontend/src/lib/components/schedules/SchedulesList.svelte index 7935cd6725..c2b81434a7 100644 --- a/frontend/src/lib/components/schedules/SchedulesList.svelte +++ b/frontend/src/lib/components/schedules/SchedulesList.svelte @@ -15,6 +15,8 @@ import Dropdown from '$lib/components/DropdownV2.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import Popover from '$lib/components/Popover.svelte' + import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' + import ScheduleUpcomingEvents from '$lib/components/schedules/ScheduleUpcomingEvents.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' import DraftBadge from '$lib/components/DraftBadge.svelte' import InheritedLabels from '$lib/components/InheritedLabels.svelte' @@ -469,7 +471,18 @@ {/if} From 3c30c82fa296a67f4a7ffab88f9176ff98bfac55 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 3 Oct 2026 09:42:45 +0200 Subject: [PATCH 020/174] chore(main): release 1.822.0 (#11480) * chore(main): release 1.822.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 29 +++ backend/Cargo.lock | 178 +++++++++--------- 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, 165 insertions(+), 136 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index afbaa8e7b0..6a0fae5efe 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.821.0" + ".": "1.822.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa66a785d..849ef9bae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [1.822.0](https://github.com/windmill-labs/windmill/compare/v1.821.0...v1.822.0) (2026-10-03) + + +### Features + +* add a cancel-only jobs:cancel token scope, optionally path-scoped ([#11479](https://github.com/windmill-labs/windmill/issues/11479)) ([4bc7e0d](https://github.com/windmill-labs/windmill/commit/4bc7e0d7bad22e9e1a3532ca747ed863f90213ba)) +* add an instance setting routing all dependency jobs to one tag ([#11486](https://github.com/windmill-labs/windmill/issues/11486)) ([3d1d249](https://github.com/windmill-labs/windmill/commit/3d1d2495567274b4c48babb374e7716c4ed24ecc)) +* add trigger, tag, run-as and digest claims to job OIDC tokens ([#11481](https://github.com/windmill-labs/windmill/issues/11481)) ([4beb1f9](https://github.com/windmill-labs/windmill/commit/4beb1f9420524425c354e82729e4e35c5bf65451)) +* log pg job progress, warn on stalls, detect dead db connections ([#11491](https://github.com/windmill-labs/windmill/issues/11491)) ([d722772](https://github.com/windmill-labs/windmill/commit/d7227726be2b082bbfb204abb75a3ff081db77de)) +* read the OIDC signing key from a file and rotate it ([#11482](https://github.com/windmill-labs/windmill/issues/11482)) ([d949484](https://github.com/windmill-labs/windmill/commit/d949484e7ba9911cf128387e096f78c917fb3f35)) +* replace the AI input filling toggle with an additional prompt ([#11501](https://github.com/windmill-labs/windmill/issues/11501)) ([e952298](https://github.com/windmill-labs/windmill/commit/e952298f84d04cbb11f0c44c32435f0b711bf3bf)) +* restricted job tokens per script and flow ([#11484](https://github.com/windmill-labs/windmill/issues/11484)) ([e7fc1b2](https://github.com/windmill-labs/windmill/commit/e7fc1b2e2e9b358b414d0ca0eaf2753e142dded7)) + + +### Bug Fixes + +* keep a resource default set in the schema when the script is redeployed ([#11495](https://github.com/windmill-labs/windmill/issues/11495)) ([68f8f19](https://github.com/windmill-labs/windmill/commit/68f8f19b33768451ebab267b36f931a6f386c678)) +* let instances withhold signing secrets from the settings API ([#11483](https://github.com/windmill-labs/windmill/issues/11483)) ([99e96d3](https://github.com/windmill-labs/windmill/commit/99e96d3f781decda8fed2cbd5409cbc5c3520eaa)) +* let legacy draft-only items be discarded from the home page ([#11488](https://github.com/windmill-labs/windmill/issues/11488)) ([98274a7](https://github.com/windmill-labs/windmill/commit/98274a73361618ff37a929584e5b5f519b71a912)) +* never reuse a pg connection a script left inside a transaction ([#11497](https://github.com/windmill-labs/windmill/issues/11497)) ([b7f74cd](https://github.com/windmill-labs/windmill/commit/b7f74cd56294cdb7052e311c5a68de9ee0bb4122)) +* read inet and cidr results with their prefix, including arrays ([#11476](https://github.com/windmill-labs/windmill/issues/11476)) ([ae093b7](https://github.com/windmill-labs/windmill/commit/ae093b7666cefc3777be0e45144bb272732846d0)) +* stop the pg executor cache from pinning a pooler slot ([#11490](https://github.com/windmill-labs/windmill/issues/11490)) ([eed7e7d](https://github.com/windmill-labs/windmill/commit/eed7e7d9e6bb375daebbb323b1288c36a278f977)) +* unstick postgres jobs on large results with custom-typed columns ([#11475](https://github.com/windmill-labs/windmill/issues/11475)) ([14801fd](https://github.com/windmill-labs/windmill/commit/14801fdd68407add824bfa2c679c0e8cb4788610)) + + +### Performance Improvements + +* keep a pg executor connection per database ([#11492](https://github.com/windmill-labs/windmill/issues/11492)) ([013b302](https://github.com/windmill-labs/windmill/commit/013b302d484b967443cbf747fb9f2ecbb1145033)) + ## [1.821.0](https://github.com/windmill-labs/windmill/compare/v1.820.0...v1.821.0) (2026-10-01) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index dcbaaede68..dcaf000d1a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1963,18 +1963,18 @@ dependencies = [ [[package]] name = "bon" -version = "3.10.1" +version = "3.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9" +checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2" dependencies = [ "bon-macros", ] [[package]] name = "bon-macros" -version = "3.10.1" +version = "3.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6" +checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5" dependencies = [ "darling 0.24.1", "ident_case", @@ -2316,9 +2316,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "jobserver", @@ -7268,9 +7268,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libffi-sys" @@ -14416,9 +14416,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.1" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.821.0" +version = "1.822.0" dependencies = [ "async-stream", "async-trait", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "argon2", @@ -15105,7 +15105,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15145,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15171,7 +15171,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.821.0" +version = "1.822.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15198,7 +15198,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15220,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15259,7 +15259,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15303,7 +15303,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-nats", @@ -15353,7 +15353,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15378,7 +15378,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15406,7 +15406,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15428,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15448,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15490,7 +15490,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.821.0" +version = "1.822.0" dependencies = [ "lazy_static", "serde", @@ -15531,7 +15531,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.821.0" +version = "1.822.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15555,7 +15555,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.821.0" +version = "1.822.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15608,7 +15608,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.821.0" +version = "1.822.0" dependencies = [ "chrono", "lazy_static", @@ -15622,7 +15622,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15641,7 +15641,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.821.0" +version = "1.822.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15755,7 +15755,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.821.0" +version = "1.822.0" dependencies = [ "chrono", "futures", @@ -15775,7 +15775,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.821.0" +version = "1.822.0" dependencies = [ "regex", "serde", @@ -15792,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15819,7 +15819,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "futures", @@ -15836,7 +15836,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.821.0" +version = "1.822.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15852,7 +15852,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -15873,7 +15873,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -15904,7 +15904,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "arc-swap", @@ -15929,7 +15929,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-stream", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "futures", @@ -15983,7 +15983,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.821.0" +version = "1.822.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15992,7 +15992,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -16004,7 +16004,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde_json", @@ -16016,7 +16016,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "gosyn", @@ -16028,7 +16028,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -16040,7 +16040,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde_json", @@ -16052,7 +16052,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "nu-parser", @@ -16063,7 +16063,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16086,7 +16086,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-recursion", @@ -16119,7 +16119,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde_json", @@ -16131,7 +16131,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -16145,7 +16145,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16162,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -16175,7 +16175,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde", @@ -16187,7 +16187,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -16205,7 +16205,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16221,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16237,7 +16237,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -16251,7 +16251,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-recursion", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "const_format", @@ -16330,7 +16330,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.821.0" +version = "1.822.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16341,7 +16341,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-recursion", @@ -16376,7 +16376,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16400,7 +16400,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16433,7 +16433,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16460,7 +16460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16493,7 +16493,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16513,7 +16513,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16547,7 +16547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16584,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16607,7 +16607,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16631,7 +16631,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-nats", @@ -16655,7 +16655,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16718,7 +16718,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-trait", @@ -16743,7 +16743,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16762,7 +16762,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-once-cell", @@ -16881,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.821.0" +version = "1.822.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e32920b180..af0d8b3a14 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.821.0" +version = "1.822.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.821.0" +version = "1.822.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 62c0cc5a8d..91d488fcf9 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.821.0" +version = "1.822.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.821.0" +version = "1.822.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.821.0" +version = "1.822.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.821.0" +version = "1.822.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 387a98aa1f..ae09ba533d 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.821.0" +version = "1.822.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3b915f0b55..ce35db405d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.821.0 + version: 1.822.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 1ef3f82939..60215e6249 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.821.0"; +export const VERSION = "v1.822.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 9d41c6cbfc..652572d7a0 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.821.0", + "version": "1.822.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.821.0", + "version": "1.822.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 6a189eb31b..7c91c0b548 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.821.0", + "version": "1.822.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 961a4474bd..4f2a481ecb 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.821.0"; +export const VERSION = "1.822.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 12227052a2..aa86f89cea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.821.0", + "version": "1.822.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.821.0", + "version": "1.822.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 13602b01f9..ba1a21a309 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.821.0", + "version": "1.822.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 b90f9b3b74..017aa4852f 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.821.0" +wmill = ">=1.822.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 1a09ce983e..c1902b8d48 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.821.0 + version: 1.822.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 0dc862dff7..73c66b2cd0 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.821.0' + ModuleVersion = '1.822.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 86b3e3cc22..c5afbe0d3d 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.821.0" +version = "1.822.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 4453b07b66..e08fa56e7c 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.821.0", + "version": "1.822.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 ec2b8bcd94..ab3fa68f56 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.821.0", + "version": "1.822.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 7254c11d2d..0ec12c5074 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.821.0 +1.822.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index d3e98974ef..2d69813554 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.821.0", + "version": "1.822.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.821.0", + "version": "1.822.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 3fcc603a52..ade5fc4c0b 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.821.0", + "version": "1.822.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From d4a423f567e1fb5e21016a2be61d858ab61a4d4f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 3 Oct 2026 11:14:36 +0200 Subject: [PATCH 021/174] fix: bound the pg cached-connection reset probe so a vanished server fails fast (#11505) Claude-Session: https://claude.ai/code/session_01KHjj2iG8kKPCaFuK6bqXvi Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-worker/src/pg_executor.rs | 29 ++++++++++++---------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index b13cfa4535..ac6a2ae79b 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -75,6 +75,10 @@ const PG_MAX_IDLE_CONNECTIONS: usize = 8; /// longer than this may be waiting on such a slot, so no idle connection is /// kept, in any scope, until it finishes. const PG_OWN_CONNECTION_STALL: Duration = Duration::from_secs(5); +/// The session reset run before reusing a cached connection is a few trivial +/// statements. A server that vanished without closing the socket never answers +/// it, and waiting for TCP to notice takes the keepalive budget (~2 min). +const PG_RESET_PROBE_TIMEOUT: Duration = Duration::from_secs(5); struct PgConnectionCache { /// Least recently used first. @@ -1243,19 +1247,18 @@ pub async fn do_postgresql( // script workloads). // // Doubles as a liveness probe — if the connection is broken any - // statement in the chain fails and we replace it. - if lease - .client() - .batch_execute( - "RESET ALL; \ - RESET SESSION AUTHORIZATION; \ - UNLISTEN *; \ - CLOSE ALL; \ - SELECT pg_advisory_unlock_all();", - ) - .await - .is_ok() - { + // statement in the chain fails, or it never answers, and we replace it. + let reset = lease.client().batch_execute( + "RESET ALL; \ + RESET SESSION AUTHORIZATION; \ + UNLISTEN *; \ + CLOSE ALL; \ + SELECT pg_advisory_unlock_all();", + ); + if matches!( + tokio::time::timeout(PG_RESET_PROBE_TIMEOUT, reset).await, + Ok(Ok(())) + ) { tracing::info!("Using cached connection"); CACHE_HITS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); reused_cached_connection = true; From 0f65df24d2cabd9d8daa71c103969e6392e37b4f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Sat, 3 Oct 2026 20:18:44 +0200 Subject: [PATCH 022/174] feat: redesign the service logs page (#11506) * feat: redesign the service logs page Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop a stale jump after a cached host switch and remove unused max_lines Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep focus on url sync and expose host selection and new logs to assistive tech Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reset the tail on dropdown host picks, resume refresh after search, key log cache by host Co-Authored-By: Claude Opus 5.5 (1M context) * fix: clear the pending jump when a host switch needs no fetch Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../lib/components/LogSnippetViewer.svelte | 13 +- .../lib/components/ServiceLogsInner.svelte | 1036 +++++++++++------ .../(root)/(logged)/service_logs/+page.svelte | 76 +- 3 files changed, 680 insertions(+), 445 deletions(-) diff --git a/frontend/src/lib/components/LogSnippetViewer.svelte b/frontend/src/lib/components/LogSnippetViewer.svelte index 8aa1ad9dec..ce913cb6c2 100644 --- a/frontend/src/lib/components/LogSnippetViewer.svelte +++ b/frontend/src/lib/components/LogSnippetViewer.svelte @@ -36,9 +36,10 @@ let html = highlightSnippet(untrack(() => content)) - + diff --git a/frontend/src/lib/components/ServiceLogsInner.svelte b/frontend/src/lib/components/ServiceLogsInner.svelte index 16bdd7d2ad..85c6cf7fb9 100644 --- a/frontend/src/lib/components/ServiceLogsInner.svelte +++ b/frontend/src/lib/components/ServiceLogsInner.svelte @@ -1,7 +1,4 @@ - + {#snippet actions()} {/snippet} -
-
...
{#each content.split('\n') as line, index}
{@html line}
{/each}...
-
+
{#each content.split('\n') as line, index}
{@html line}
{/each}
- - {#snippet left_pane()} -
-
- { - minTs = undefined - maxTs = undefined - allLogs = undefined - const ts = timeframe.computeMinMax() - getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined) - }} - /> -
-
0' }} - on:change={() => { - allLogs = undefined - getAllLogs(minTs, maxTs) - }} - /> - { - if (e.detail) { - getAllLogs(maxTs, undefined) - } else { - timeout && clearTimeout(timeout) - } - }} - options={{ right: 'auto-refresh' }} - />
- {#if searchError} -
- - {searchError} - +
+
+ {@render title?.()} +
+ + + {#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0} +
+ + + {#snippet text()} +
+ Some search terms were ignored because they could not be parsed: +
    + {#each queryParseErrors ?? [] as msg} +
  • {msg}
  • + {/each} +
+
+ {/snippet} +
{/if} - {#if allLogs == undefined} -
- {:else if Object.keys(allLogs).length == 0} -
- No logs - Search only covers a recent time window, configurable in instance settings under - Indexer. -
- {:else if minTs && maxTs} - {@const minTsN = new Date(minTs).getTime()} - {@const maxTsN = new Date(maxTs).getTime()} - {@const diff = maxTsN - minTsN} - {#if searchTerm === ''} -
-
+
+ { + minTs = undefined + maxTs = undefined + allLogs = undefined + const ts = timeframe.computeMinMax() + getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined) + }} + /> +
+ { + allLogs = undefined + getAllLogs(minTs, maxTs) + }} + /> + { + if (e.detail) { + getAllLogs(maxTs, undefined) + } else { + timeout && clearTimeout(timeout) + } + }} + options={{ + right: 'Auto-refresh', + rightTooltip: 'Fetch new log files every 5 seconds. Paused while searching.' + }} + /> +
+
-
{new Date(minTs).toLocaleTimeString([], { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - })}
{new Date(maxTs).toLocaleTimeString([], { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - })}
-
- {/if} -
- selected, + (v) => (v ? selectHost(v.mode, v.workerGroup, v.hostname) : (selected = v)) + } + items={getSelectItems(allLogs, countsPerHost)} + onClear={() => { + selected = undefined + }} + size="sm" + placeholder="Find a host" + /> +
+ {@const minTsN = minTs ? new Date(minTs).getTime() : 0} + {@const diff = maxTs && minTs ? new Date(maxTs).getTime() - minTsN || 1 : 1} +
+ {#if searchTerm === '' && minTs && maxTs} + +
+ +
+ {formatAxisTime(minTs, maxTs)} + {formatAxisTime(maxTs, minTs)} +
+
+ {/if} + {#each sortedEntries(allLogsOrQueryResults(allLogs, countsPerHost)) as [mode, o1]} +
+

{modeLabel(mode, true)}

+ {#each sortedEntries(o1) as [wg, o2]} + {@const hosts = visibleHosts(mode, wg, o2)} + {#if hosts.length > 0} +
+ {#if wg} + {wg} + {/if} + {#each hosts as [hn, files]} + {@const hostKey = `${mode},${wg},${hn}`} + {@const hostMax = Math.max( + 1, + ...files.map((f) => f.ok_lines + f.err_lines) + )} + + {/each} +
+ {/if} + {/each} +
+ {/each} + {#if !loadingLogCounts && sumOtherDocCount != 0} + + {sumOtherDocCount} more matches are not attributed to any of these hosts. + + {/if} +
+ {/if}
- {#each Object.entries(allLogsOrQueryResults(allLogs, countsPerHost)) as [mode, o1]} -
-

{mode}s

- {#each Object.entries(o1) as [wg, o2]} -
- {#if wg && wg != ''} -

{wg}

+ {/snippet} + {#snippet right_pane()} +
+ {#if selected} +
+
+
+

+ {modeLabel(selected.mode, false)} +

+ {selected.hostname} +
+ {#if selected.workerGroup} + + Worker group {selected.workerGroup} + {/if} -
- {#each Object.entries(o2).filter(([hn, files]) => { - if (selected && selected.mode === mode && selected.workerGroup === wg && selected.hostname === hn) { - return true - } - const hostKey = `${mode},${wg},${hn}` - if (countsPerHost && (countsPerHost[hostKey] == undefined || countsPerHost[hostKey].doc_count === 0)) { - return false - } - return true - }) as [hn, files]} - {@const hostKey = `${mode},${wg},${hn}`} - - -
{ - selected = { mode, workerGroup: wg, hostname: hn } - upToIsLatest = true - upTo = getLatestUpTo(selected) - scrollToBottom() +
+ {#if searchTerm == ''} +
+ Last 5 log files up to + +
+ {:else if logs != undefined && !loadingLogs} + + {(logs.hits ?? []).length} matches on this host + + {/if} +
+ {#if searchTerm == ''} + + Logs appear about a minute after they are written, once their file is compacted. + + {/if} +
+
+ {#if loadingLogs} +
+ +
+ {:else if logs != undefined} +
+ + {#each logs.hits ?? [] as hit, i (`${i}:${hit.file_path}:${hit.line_no}`)} + seeLogContext(hit.line_no, hit.file_path, hit.host, true)} + /> + {/each} + {#if (logs.hits ?? []).length === 0} +
+ +
+ {/if} + {#if (logs.hits ?? []).length === 1000} + + Only the 1000 most recent matches are shown. Narrow the search or the time + range to see older ones. + + {/if} +
+ {:else} + + {#each getLogs(selected, upTo) as file (file.file_path)} + {@const entry = logsContent[fileKey(selected.hostname, file.file_path)]} +
{truncateRev(hn, countsPerHost || loadingLogs ? 40 : 8)}
- {#if loadingLogCounts} - - {:else if countsPerHost} -
- {countsPerHost[hostKey]?.doc_count ?? 0} matches + {formatTime(file.ts)} +
+ {#if entry == undefined} +
+ {:else if entry.error} +
+ {#if entry.error?.startsWith('Not Found')} + + Servers and workers need a shared log volume, or the EE object storage + for logs set in the instance settings, for their log files to be + readable from here. + + {:else} + + {entry.error} + + {/if}
+ {:else if entry.content} + {:else} -
- {#each files as file} - {@const okHeight = 100.0 * ((file.ok_lines * 1.0) / (max_lines ?? 1))} - {@const errHeight = 100.0 * ((file.err_lines * 1.0) / (max_lines ?? 1))} -
-
- {/each} -
+

This log file is empty.

{/if}
{/each} -
-
- {/each} -
- {/each} - {#if !loadingLogCounts && sumOtherDocCount != 0} -
- Note: {sumOtherDocCount} additional matches weren't grouped into any of the above hosts. -
- {/if} - {/if} -
- {/snippet} - {#snippet right_pane()} -
- {#if selected} - {#if !loadingLogs && logs == undefined} -
- 1 min delay: logs are compacted before being available -
- {/if} -
- {#if loadingLogs} -
-
- -
-
- {:else if logs != undefined} -
- - {#each logs.hits ?? [] as hit, i (`${i}:${hit.file_path}:${hit.line_no}`)} - seeLogContext(hit.line_no, hit.file_path, hit.host, true)} - /> - {/each} - {#if (logs.hits ?? []).length === 0} -
No logs
- {/if} - {#if (logs.hits ?? []).length === 1000} -
- Older matches were truncated from this search, try refining your filters to get - more precise results. -
- {/if} -
-
- {:else} - {#each getLogs(selected, upTo) as file} -
-
{new Date(file.ts).toLocaleTimeString([], { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - })}
- {#if logsContent[file.file_path] == undefined} -
- {:else if logsContent[file.file_path]} - {#if logsContent[file.file_path].error} - {#if logsContent[file.file_path].error?.startsWith('Not Found')} -
Log file is missing. Log files require a shared log volume to be mounted - across servers and workers or to use the EE S3/object storage integration - for logs. To avoid mounting a shared volume, set the EE object store logs in - the instance settings
- {:else} -
{logsContent[file.file_path].error}
- {/if} - {:else if logsContent[file.file_path].content} - - -
- {:else} -
No logs
- {/if} {/if}
- {/each} + {#if showScrollToEnd && logs == undefined} +
+ + {#if hasNewLogs} + + + + + {/if} +
+ {/if} + + + {hasNewLogs ? 'New logs available below' : ''} + +
+ {:else} +
+ +
{/if}
- {#if searchTerm == ''} -
-
Last 5 log files up to:
-
- {#if upTo} - - {:else} -
- {/if} + {/snippet} + +
+
-
-
- {#if upTo} - - {:else} -
- {/if} -
-
- -
-
- {/if} - {:else} -
Select a host to see its logs
- {/if}
- {/snippet} - + diff --git a/frontend/src/routes/(root)/(logged)/service_logs/+page.svelte b/frontend/src/routes/(root)/(logged)/service_logs/+page.svelte index cab2a14790..5b8e56b5a2 100644 --- a/frontend/src/routes/(root)/(logged)/service_logs/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/service_logs/+page.svelte @@ -1,72 +1,34 @@ -
-
-
-
-

Service logs

- Explore and search Windmill service logs from within Windmill! -
-
+{#snippet title()} +
+

Service logs

+ + Logs written by the servers, workers and indexers of this instance, grouped by host. +
+{/snippet} +
{#if $devopsRole == false} - - Service logs are only available to superadmins (or devops) - - {:else} -
- - - - {#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0} - - - {#snippet text()} - Some of your search terms have been ignored because one or more parse errors:

- {#if queryParseErrors} -
    - {#each queryParseErrors as msg} -
  • - {msg}
  • - {/each} -
- {/if} - {/snippet} -
- {/if} +
+ {@render title()} + + Ask one of them to look at the logs, or to grant you the devops role. +
- - + {:else} + {/if}
- - From 35ec16d12728531ab90632116913e98f80d1cc05 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Sat, 3 Oct 2026 20:19:06 +0200 Subject: [PATCH 023/174] fix: delete a flow chat turn with its flow run, not its step jobs (#11477) * fix: delete a flow chat turn with its flow run, not its step jobs Co-Authored-By: Claude Opus 5.5 (1M context) * fix: give flow-written answers and overlapping turns their own flow run Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ...de8c72e312ef8abd5ed29482a59c8ec51dd7.json} | 5 +- ...4b1973a48d47c1c563b2213055bfcf688230.json} | 4 +- ...c76d5569621612ade0270261c172102015b4.json} | 4 +- ..._conversation_message_turn_job_id.down.sql | 1 + ...ow_conversation_message_turn_job_id.up.sql | 23 ++++++ backend/tests/v2_job_delete_orphans.rs | 70 +++++++++++++++++++ backend/windmill-api-jobs/src/execution.rs | 1 + backend/windmill-api-jobs/src/jobs_export.rs | 3 +- .../windmill-common/src/flow_conversations.rs | 7 +- backend/windmill-common/src/jobs.rs | 11 +-- backend/windmill-worker/src/ai/tools.rs | 9 +-- backend/windmill-worker/src/ai/utils.rs | 2 + backend/windmill-worker/src/ai_executor.rs | 7 +- backend/windmill-worker/src/worker_flow.rs | 5 +- 14 files changed, 131 insertions(+), 21 deletions(-) rename backend/.sqlx/{query-12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c.json => query-a6dafb34be6088886b078ed2b554de8c72e312ef8abd5ed29482a59c8ec51dd7.json} (68%) rename backend/.sqlx/{query-967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352.json => query-d5f1e272af4a428daedafa3453124b1973a48d47c1c563b2213055bfcf688230.json} (57%) rename backend/.sqlx/{query-69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976.json => query-fa81e10f725586e12c77d4f6387cc76d5569621612ade0270261c172102015b4.json} (67%) create mode 100644 backend/migrations/20260929064555_flow_conversation_message_turn_job_id.down.sql create mode 100644 backend/migrations/20260929064555_flow_conversation_message_turn_job_id.up.sql diff --git a/backend/.sqlx/query-12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c.json b/backend/.sqlx/query-a6dafb34be6088886b078ed2b554de8c72e312ef8abd5ed29482a59c8ec51dd7.json similarity index 68% rename from backend/.sqlx/query-12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c.json rename to backend/.sqlx/query-a6dafb34be6088886b078ed2b554de8c72e312ef8abd5ed29482a59c8ec51dd7.json index 4c120699aa..bde8b28723 100644 --- a/backend/.sqlx/query-12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c.json +++ b/backend/.sqlx/query-a6dafb34be6088886b078ed2b554de8c72e312ef8abd5ed29482a59c8ec51dd7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning, attachments)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, turn_job_id, step_name, success, tool_arguments, tool_result, reasoning, attachments)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", "describe": { "columns": [], "parameters": { @@ -20,6 +20,7 @@ }, "Text", "Uuid", + "Uuid", "Varchar", "Bool", "Text", @@ -30,5 +31,5 @@ }, "nullable": [] }, - "hash": "12329c3359a7944ab5fa3aa27ddca1b26f340ccf574b9fa07641fe88b2d2987c" + "hash": "a6dafb34be6088886b078ed2b554de8c72e312ef8abd5ed29482a59c8ec51dd7" } diff --git a/backend/.sqlx/query-967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352.json b/backend/.sqlx/query-d5f1e272af4a428daedafa3453124b1973a48d47c1c563b2213055bfcf688230.json similarity index 57% rename from backend/.sqlx/query-967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352.json rename to backend/.sqlx/query-d5f1e272af4a428daedafa3453124b1973a48d47c1c563b2213055bfcf688230.json index 5161f716ff..190c14788f 100644 --- a/backend/.sqlx/query-967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352.json +++ b/backend/.sqlx/query-d5f1e272af4a428daedafa3453124b1973a48d47c1c563b2213055bfcf688230.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id", + "query": "DELETE FROM flow_conversation_message WHERE COALESCE(turn_job_id, job_id) = ANY($1)\n RETURNING conversation_id", "describe": { "columns": [ { @@ -18,5 +18,5 @@ false ] }, - "hash": "967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352" + "hash": "d5f1e272af4a428daedafa3453124b1973a48d47c1c563b2213055bfcf688230" } diff --git a/backend/.sqlx/query-69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976.json b/backend/.sqlx/query-fa81e10f725586e12c77d4f6387cc76d5569621612ade0270261c172102015b4.json similarity index 67% rename from backend/.sqlx/query-69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976.json rename to backend/.sqlx/query-fa81e10f725586e12c77d4f6387cc76d5569621612ade0270261c172102015b4.json index 52105ca605..6fbc58c626 100644 --- a/backend/.sqlx/query-69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976.json +++ b/backend/.sqlx/query-fa81e10f725586e12c77d4f6387cc76d5569621612ade0270261c172102015b4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)\n RETURNING m.conversation_id", + "query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1\n AND COALESCE(m.turn_job_id, m.job_id) = ANY($2)\n RETURNING m.conversation_id", "describe": { "columns": [ { @@ -19,5 +19,5 @@ false ] }, - "hash": "69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976" + "hash": "fa81e10f725586e12c77d4f6387cc76d5569621612ade0270261c172102015b4" } diff --git a/backend/migrations/20260929064555_flow_conversation_message_turn_job_id.down.sql b/backend/migrations/20260929064555_flow_conversation_message_turn_job_id.down.sql new file mode 100644 index 0000000000..5906eda25d --- /dev/null +++ b/backend/migrations/20260929064555_flow_conversation_message_turn_job_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE flow_conversation_message DROP COLUMN turn_job_id; diff --git a/backend/migrations/20260929064555_flow_conversation_message_turn_job_id.up.sql b/backend/migrations/20260929064555_flow_conversation_message_turn_job_id.up.sql new file mode 100644 index 0000000000..db9427692b --- /dev/null +++ b/backend/migrations/20260929064555_flow_conversation_message_turn_job_id.up.sql @@ -0,0 +1,23 @@ +-- The flow run a row's turn belongs to: the job the user message started. Retention collects +-- rows by it, so a turn goes as one unit. `job_id` names the step or tool job that wrote the +-- row, which completes, and so expires, before the run does. +ALTER TABLE flow_conversation_message ADD COLUMN turn_job_id UUID; + +-- Turns can overlap in one conversation, so a row's turn is the user row its job descends +-- from. Transcript order only decides for rows whose job is already gone or that have none. +UPDATE flow_conversation_message m + SET turn_job_id = COALESCE( + (SELECT u.job_id FROM v2_job j + JOIN flow_conversation_message u + ON u.conversation_id = m.conversation_id + AND u.message_type = 'user' + AND u.job_id IN (j.id, j.parent_job, j.root_job, j.flow_innermost_root_job) + WHERE j.id = m.job_id + LIMIT 1), + (SELECT u.job_id FROM flow_conversation_message u + WHERE u.conversation_id = m.conversation_id + AND u.message_type = 'user' + AND u.created_seq <= m.created_seq + ORDER BY u.created_seq DESC + LIMIT 1) + ); diff --git a/backend/tests/v2_job_delete_orphans.rs b/backend/tests/v2_job_delete_orphans.rs index 2b11deab77..45029f024d 100644 --- a/backend/tests/v2_job_delete_orphans.rs +++ b/backend/tests/v2_job_delete_orphans.rs @@ -205,6 +205,75 @@ async fn test_delete_jobs_removes_a_conversation_once_its_last_message_goes( Ok(()) } +/// The agent step completes, and so expires, before the flow run that holds the question: its +/// job going must leave the answer in place, and the run going must take the whole turn. +#[sqlx::test(fixtures("base"))] +async fn test_delete_jobs_removes_a_turn_with_its_flow_run( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let flow_job = Uuid::new_v4(); + let step_job = Uuid::new_v4(); + insert_job(&db, WS, flow_job).await?; + insert_job(&db, WS, step_job).await?; + + let conv_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by) + VALUES ($1, $2, 'f/flow', 'test-user')", + ) + .bind(conv_id) + .bind(WS) + .execute(&db) + .await?; + let mut tx = db.begin().await?; + for (job_id, message_type) in [ + ( + flow_job, + windmill_common::flow_conversations::MessageType::User, + ), + ( + step_job, + windmill_common::flow_conversations::MessageType::Assistant, + ), + ] { + windmill_common::flow_conversations::add_message_to_conversation_tx( + &mut tx, + conv_id, + Some(job_id), + flow_job, + "hi", + message_type, + None, + true, + None, + ) + .await?; + } + tx.commit().await?; + + let messages = "SELECT count(*) FROM flow_conversation_message WHERE conversation_id = $1"; + let mut conn = db.acquire().await?; + windmill_common::jobs::delete_jobs(&mut conn, &[step_job]).await?; + drop(conn); + assert_eq!( + count(&db, messages, conv_id).await?, + 2, + "the step's job going must keep the turn" + ); + + let mut conn = db.acquire().await?; + windmill_common::jobs::delete_jobs(&mut conn, &[flow_job]).await?; + drop(conn); + assert_eq!( + count(&db, messages, conv_id).await?, + 0, + "the run going must take the turn" + ); + Ok(()) +} + /// Turns that start while retention is collecting their conversation must land, not fail: /// the conversation lookup locks the row, so each turn waits for the collector's commit, /// finds the conversation gone, and creates it again — the first insert wins and the other @@ -265,6 +334,7 @@ async fn test_new_turns_wait_for_conversation_cleanup_and_recreate( &mut tx, conv_id, Some(new_job), + new_job, "hi again", windmill_common::flow_conversations::MessageType::User, None, diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 8c13483e41..c35b9e41b0 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -828,6 +828,7 @@ pub async fn handle_chat_conversation_messages( tx, memory_id, Some(job_id), + job_id, &user_message, MessageType::User, None, diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index d60edc3a3f..b2b69367bc 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -718,7 +718,8 @@ pub async fn delete_jobs( let mut conversation_ids: Vec = sqlx::query_scalar!( "DELETE FROM flow_conversation_message m USING flow_conversation c - WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2) + WHERE m.conversation_id = c.id AND c.workspace_id = $1 + AND COALESCE(m.turn_job_id, m.job_id) = ANY($2) RETURNING m.conversation_id", &w_id, &job_ids diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 5ee99d0681..bde4d5ccce 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -278,6 +278,8 @@ pub async fn add_message_to_conversation_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, conversation_id: Uuid, job_id: Option, + // The flow run the user message started; retention deletes the turn's rows by it. + turn_job_id: Uuid, content: &str, message_type: MessageType, step_name: Option<&str>, @@ -303,12 +305,13 @@ pub async fn add_message_to_conversation_tx( // Insert the message sqlx::query!( - "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning, attachments) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, turn_job_id, step_name, success, tool_arguments, tool_result, reasoning, attachments) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", conversation_id, message_type as MessageType, content, job_id, + turn_job_id, step_name, success, extras.and_then(|e| e.tool_arguments.as_deref()), diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index d437881b7b..1a314d34c0 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -554,9 +554,11 @@ pub static WORKER_INTERNAL_SERVER_INLINE_UTILS: OnceCell error::Result<()> { sqlx::query!( "DELETE FROM dispatch_event WHERE producer_job_id = ANY($1)", @@ -565,7 +567,8 @@ pub async fn delete_jobs(conn: &mut sqlx::PgConnection, ids: &[uuid::Uuid]) -> e .execute(&mut *conn) .await?; let mut conversation_ids: Vec = sqlx::query_scalar!( - "DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id", + "DELETE FROM flow_conversation_message WHERE COALESCE(turn_job_id, job_id) = ANY($1) + RETURNING conversation_id", ids ) .fetch_all(&mut *conn) diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 9e50ddd6c0..67c16e966e 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -4,7 +4,7 @@ use crate::ai::utils::{ is_completed_input_transform, update_flow_status_module_with_actions, update_flow_status_module_with_actions_success, FlowContext, }; -use crate::common::OccupancyMetrics; +use crate::common::{get_root_job_id, OccupancyMetrics}; use crate::result_processor::handle_non_flow_job_error; use crate::worker_flow::{ evaluate_input_transform, raw_script_to_payload, resolve_flow_step_tag, script_to_payload, @@ -971,11 +971,7 @@ fn windmill_tool_row( /// Add tool message to conversation if chat is enabled async fn add_tool_message_to_chat( ctx: &mut ToolExecutionContext<'_>, - // The job this row belongs to: the tool's own where it has one, else the agent's, which - // is the job it ran inside. Every row names one so that retention collects the whole - // turn — `delete_jobs` removes messages by `job_id = ANY(..)` (there is no FK on the - // column; `drop_v2_job_side_table_cascades` dropped it), and a row naming no job would - // survive every purge and leave a conversation that can never become empty. + // The tool's own job where it has one, else the agent's, which is the job it ran inside. tool_job_id: Option, content: &str, success: bool, @@ -1015,6 +1011,7 @@ async fn add_tool_message_to_chat( ctx.db, &memory_id, tool_job_id, + get_root_job_id(ctx.job), content, MessageType::Tool, &step_name, diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index 3efdc67a3d..1626f648c4 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -209,6 +209,7 @@ pub async fn add_message_to_conversation( db: &DB, conversation_id: &Uuid, job_id: Option, + turn_job_id: Uuid, message_content: &str, message_type: MessageType, step_name: &Option, @@ -220,6 +221,7 @@ pub async fn add_message_to_conversation( &mut tx, *conversation_id, job_id, + turn_job_id, &message_content, message_type, step_name.as_deref(), diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 08b561358e..b1715f9833 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -64,7 +64,8 @@ use windmill_queue::{append_logs, cancel_single_job, CanceledBy, MiniPulledJob}; use crate::{ ai::stream_event_processor::StreamEventProcessor, common::{ - build_args_map, resolve_job_timeout, transform_json_value, OccupancyMetrics, StreamNotifier, + build_args_map, get_root_job_id, resolve_job_timeout, transform_json_value, + OccupancyMetrics, StreamNotifier, }, handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome}, }; @@ -1968,6 +1969,7 @@ pub async fn run_agent( db, &conversation_id, Some(job.id), + get_root_job_id(job), "Used websearch tool", MessageType::Tool, &step_name, @@ -2020,6 +2022,7 @@ pub async fn run_agent( db, &conversation_id, Some(job.id), + get_root_job_id(job), response_content, MessageType::Assistant, &step_name, @@ -2150,6 +2153,7 @@ pub async fn run_agent( db, &conversation_id, Some(job.id), + get_root_job_id(job), answer, MessageType::Assistant, &step_name, @@ -2201,6 +2205,7 @@ pub async fn run_agent( db, &conversation_id, Some(job.id), + get_root_job_id(job), &message_content, MessageType::Assistant, &step_name, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 445b9af720..0d46cadb25 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2056,6 +2056,7 @@ pub async fn update_flow_status_after_job_completion_internal( add_tool_message_to_conversation( db, &job_id_for_status, + flow, success, skipped, chat_ai_info.is_ai_agent_step, @@ -2233,6 +2234,7 @@ fn extract_chat_message_from_flow_result(result: &RawValue) -> error::Result Date: Sun, 4 Oct 2026 08:55:30 +0200 Subject: [PATCH 024/174] fix: drop a fork's datatable database despite connected sessions (#11509) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/workspaces_extra.rs | 91 ++++++++++++++++++- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 7e2958bb58..a0ff1f2370 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -1670,13 +1670,12 @@ pub async fn drop_forked_datatable_databases( match parent_pg.connect(Some(&db)).await { Ok((client, connection)) => { let join_handle = tokio::spawn(async move { connection.await }); - if let Err(e) = client - .execute(&format!("DROP DATABASE \"{}\"", db_to_drop), &[]) - .await - { + if let Err(e) = drop_database_with_sessions(&client, db_to_drop).await { errors.push(format!( "Could not drop database '{}' for datatable://{}: {}", - db_to_drop, dt_name, e + db_to_drop, + dt_name, + windmill_common::error::pg_error_message(&e) )); } drop(client); @@ -1695,6 +1694,44 @@ pub async fn drop_forked_datatable_databases( Ok(Json(errors)) } +/// Drops `dbname`, whose name the caller validated, along with the sessions still connected to +/// it: a worker keeps an idle connection to a database it ran a job on, and a plain +/// `DROP DATABASE` fails for as long as one exists. +async fn drop_database_with_sessions( + client: &tokio_postgres::Client, + dbname: &str, +) -> std::result::Result<(), tokio_postgres::Error> { + let version = client + .query_one("SELECT current_setting('server_version_num')::int", &[]) + .await? + .get::<_, i32>(0); + if version >= 130000 { + client + .execute(&format!("DROP DATABASE \"{dbname}\" WITH (FORCE)"), &[]) + .await?; + return Ok(()); + } + // No `FORCE` before 13. The drop below waits a few seconds for the sessions to exit, and + // reports the ones this role was not allowed to terminate. + if let Err(e) = client + .execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()", + &[&dbname], + ) + .await + { + tracing::warn!( + "Failed to terminate connections to '{dbname}': {}", + windmill_common::error::pg_error_message(&e) + ); + } + client + .execute(&format!("DROP DATABASE \"{dbname}\""), &[]) + .await?; + Ok(()) +} + /// Drop this fork workspace's ducklake namespaces: the `wm_fork_*` metadata schema in each /// lake's catalog database, plus (best effort) the fork's `__wm_forks//…` data files in /// the workspace storage. Driven by the `fork_ducklake_namespace` registry written at first @@ -2265,3 +2302,47 @@ async fn workspace_is_fork(db: &DB, w_id: &str) -> Result { .await? .unwrap_or(false)) } + +#[cfg(test)] +mod tests { + use super::drop_database_with_sessions; + + #[sqlx::test(migrations = false)] + async fn a_database_is_dropped_under_a_connected_session(pool: sqlx::PgPool) { + let mut config: tokio_postgres::Config = + std::env::var("DATABASE_URL").unwrap().parse().unwrap(); + let test_db = pool.connect_options().get_database().unwrap().to_string(); + let (client, connection) = config + .dbname(&test_db) + .connect(tokio_postgres::NoTls) + .await + .unwrap(); + tokio::spawn(connection); + // The test database's name already fills an identifier. + let held_db = format!("held_{}", &test_db[test_db.len().saturating_sub(40)..]); + client + .execute(&format!("CREATE DATABASE \"{held_db}\""), &[]) + .await + .unwrap(); + let (_session, connection) = config + .dbname(&held_db) + .connect(tokio_postgres::NoTls) + .await + .unwrap(); + tokio::spawn(connection); + + drop_database_with_sessions(&client, &held_db) + .await + .unwrap(); + + let left = client + .query_one( + "SELECT count(*) FROM pg_database WHERE datname = $1", + &[&held_db], + ) + .await + .unwrap() + .get::<_, i64>(0); + assert_eq!(left, 0); + } +} From 711e08c1d20a326142235c83d8805b7990bf6e6d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 4 Oct 2026 11:40:20 +0200 Subject: [PATCH 025/174] fix: bound the datatable migration lock wait and reap a dead holder (#11510) * fix: bound the datatable migration lock wait and reap its dead holder Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 * docs: say what the migration lock keepalives cover behind a pooler Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/datatable_migrations.rs | 134 ++++++++++++++++-- 1 file changed, 123 insertions(+), 11 deletions(-) diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index 988c430b20..e63e69d4d5 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -17,6 +17,7 @@ use crate::workspaces::{ use axum::{ extract::{Extension, Path, Query}, + http::StatusCode, routing::{delete, get, post}, Json, Router, }; @@ -403,6 +404,12 @@ async fn connection_identity(client: &tokio_postgres::Client) -> Option<(String, }) } +const MIGRATION_LOCK_KEY: &str = "hashtext('windmill_datatable_migrations')::int8"; + +/// How long a run, rollback or edit waits for the one ahead of it. A migration may run for +/// longer than any request should wait, so past this the caller is told to retry. +const MIGRATION_LOCK_WAIT_SECS: u64 = 30; + /// Open a connection to a data table's own database and hold the session-level /// advisory lock that serializes migration runs/rollbacks. The lock is released /// when the returned client is dropped, so callers must keep it in scope for the @@ -428,18 +435,85 @@ async fn lock_datatable_migration_runs( tracing::error!("Datatable connection error: {}", e); } }); - client - .batch_execute("SELECT pg_advisory_lock(hashtext('windmill_datatable_migrations')::int8)") - .await - .map_err(|e| { - Error::internal_err(format!( - "Failed to acquire migration lock: {}", - pg_error_message(&e) - )) - })?; + hold_migration_lock(&client, datatable_name, MIGRATION_LOCK_WAIT_SECS).await?; Ok(client) } +async fn hold_migration_lock( + client: &tokio_postgres::Client, + datatable_name: &str, + wait_secs: u64, +) -> Result<()> { + // A session whose client vanished without closing the socket otherwise keeps the lock for + // the server's keepalive default, two hours on Linux. `SET` rather than startup `options`, + // which PgBouncer rejects. Behind a pooler they only cover the pooler's own connection to + // the server: there, as on a server refusing them, the bounded wait below is the recovery. + if let Err(e) = client + .batch_execute( + "SET tcp_keepalives_idle = 60; SET tcp_keepalives_interval = 10; \ + SET tcp_keepalives_count = 6", + ) + .await + { + tracing::debug!( + "Could not set server keepalives on the migration lock connection: {}", + pg_error_message(&e) + ); + } + let locked = client + .batch_execute(&format!( + "SET lock_timeout = '{wait_secs}s'; \ + SELECT pg_advisory_lock({MIGRATION_LOCK_KEY}); \ + RESET lock_timeout" + )) + .await; + match locked { + Ok(()) => Ok(()), + Err(e) if e.code() == Some(&SqlState::LOCK_NOT_AVAILABLE) => { + // The key as `pg_locks` splits it: high half in `classid`, low half in `objid`. + let holder = client + .query_opt( + &format!( + "SELECT pid FROM pg_locks + WHERE locktype = 'advisory' AND granted AND objsubid = 1 + AND database = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND ((classid::bigint << 32) | objid::bigint) = {MIGRATION_LOCK_KEY}" + ), + &[], + ) + .await + .ok() + .flatten() + .map(|row| row.get::<_, i32>(0)); + let held_by = match holder { + Some(pid) => format!( + " The lock is held by backend pid {pid}: if nothing is running, a lost \ + connection left that session behind, and \ + `SELECT pg_terminate_backend({pid})` on the data table's database releases it." + ), + None => String::new(), + }; + Err(Error::Generic( + StatusCode::CONFLICT, + format!( + "Another migration run, rollback or edit on the database of data table \ + '{datatable_name}' was still in progress after {wait_secs}s. \ + Retry once it is done.{held_by}" + ), + )) + } + Err(e) => Err(Error::internal_err(format!( + "Failed to acquire migration lock: {}", + pg_error_message(&e) + ))), + } +} + +/// Whether `e` is the lock wait running out, as opposed to the database being unreachable. +fn is_migration_lock_busy(e: &Error) -> bool { + matches!(e, Error::Generic(StatusCode::CONFLICT, _)) +} + /// Read the versions recorded as applied in a data table's `_wm_migrations`, /// scoped to that data table, using an existing connection. An absent table /// (`42P01`) means nothing has been migrated yet. @@ -1360,7 +1434,13 @@ async fn delete_datatable_migration( }; let lock_client = lock_datatable_migration_runs(&db, &w_id, &datatable_name) .await - .map_err(unreachable)?; + .map_err(|e| { + if is_migration_lock_busy(&e) { + e + } else { + unreachable(e) + } + })?; let applied = read_applied_versions_on_client(&lock_client, &datatable_name) .await .map_err(unreachable)?; @@ -1535,7 +1615,13 @@ async fn upsert_datatable_migration( }; let client = lock_datatable_migration_runs(&db, &w_id, &datatable_name) .await - .map_err(unreachable)?; + .map_err(|e| { + if is_migration_lock_busy(&e) { + e + } else { + unreachable(e) + } + })?; let applied = read_applied_versions_on_client(&client, &datatable_name) .await .map_err(unreachable)?; @@ -2043,6 +2129,32 @@ pub(crate) async fn clone_datatable_migrations( mod tests { use super::*; + // The holder is found by reassembling the key from the two halves `pg_locks` shows. + #[sqlx::test(migrations = false)] + async fn a_held_migration_lock_is_reported_with_its_holder(pool: DB) { + let mut config: tokio_postgres::Config = + std::env::var("DATABASE_URL").unwrap().parse().unwrap(); + config.dbname(pool.connect_options().get_database().unwrap()); + let (holder, connection) = config.connect(tokio_postgres::NoTls).await.unwrap(); + tokio::spawn(connection); + let (waiter, connection) = config.connect(tokio_postgres::NoTls).await.unwrap(); + tokio::spawn(connection); + hold_migration_lock(&holder, "main", 1).await.unwrap(); + let holder_pid = holder + .query_one("SELECT pg_backend_pid()", &[]) + .await + .unwrap() + .get::<_, i32>(0); + + let busy = hold_migration_lock(&waiter, "main", 1).await.unwrap_err(); + + assert!(is_migration_lock_busy(&busy), "{busy:?}"); + assert!( + busy.to_string().contains(&format!("pid {holder_pid}:")), + "{busy}" + ); + } + #[test] fn validate_migration_name_accepts_safe_names() { for name in ["initial", "add_index_to_customers", "fix-bug_2", "ABC123"] { From 33f6de0cd20f45ff56770f946a4731e5b13bf677 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 4 Oct 2026 11:41:12 +0200 Subject: [PATCH 026/174] fix: apply the pg keepalive settings to the postgres trigger connection (#11512) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-common/src/lib.rs | 2 +- backend/windmill-trigger-postgres/src/lib.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 704948dd87..d0e7c6d652 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -1072,7 +1072,7 @@ impl TokioPgConnection { /// Without these, a server that vanishes without closing the socket (a failover, /// a dropped route) leaves a query waiting on a read for the OS default of two /// hours. The server's kernel answers the probes, so a slow query is unaffected. -fn set_pg_keepalive(config: &mut tokio_postgres::Config) { +pub fn set_pg_keepalive(config: &mut tokio_postgres::Config) { config .keepalives(true) .keepalives_idle(std::time::Duration::from_secs(60)) diff --git a/backend/windmill-trigger-postgres/src/lib.rs b/backend/windmill-trigger-postgres/src/lib.rs index 15918fca7b..94bc2b4a5e 100644 --- a/backend/windmill-trigger-postgres/src/lib.rs +++ b/backend/windmill-trigger-postgres/src/lib.rs @@ -355,6 +355,8 @@ pub async fn get_raw_postgres_connection( config.options(options); } + windmill_common::set_pg_keepalive(&mut config); + let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?; let client = if let Some(connector) = connector { let (client, connection) = config.connect(connector).await.map_err(to_anyhow)?; From 26856b8061c28eade191c7d85a32b74ca44c4fea Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 4 Oct 2026 14:04:24 +0200 Subject: [PATCH 027/174] fix: cancel an abandoned pg query and close failed connections cleanly (#11511) * fix: cancel the query of a timed out or cancelled postgresql job Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 * test: fail the cancelled-query test unless the cancel happened Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 * fix: leave a failed pg job's connection clean for a pooler before closing it Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 * fix: cancel a failed pg job's unread results and roll back only inside a transaction Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 * fix: redo a pg connection's closing reset when a late cancel lands on it Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 * fix: let a late cancel land on the settle query without skipping the reset Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019cpWeGy8jRMtWqvuRBpgu4 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/worker.rs | 81 +++++++++++ backend/windmill-common/src/lib.rs | 43 +++++- backend/windmill-worker/src/pg_executor.rs | 155 ++++++++++++++++++--- 3 files changed, 258 insertions(+), 21 deletions(-) diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 6966b40e1f..f420dd2a12 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -1875,6 +1875,87 @@ async fn test_postgresql_open_transaction_not_reused(db: Pool) -> anyh Ok(()) } +/// Closing the connection of a cancelled job does not stop its query: the server +/// only notices once it next writes to the client. +#[sqlx::test(fixtures("base"))] +#[serial(pg_cache)] +async fn test_postgresql_cancelled_job_stops_its_query(db: Pool) -> anyhow::Result<()> { + use windmill_worker::pg_executor::clear_pg_cache; + + initialize_tracing().await; + clear_pg_cache().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let dbname: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(&db) + .await?; + let still_running = "SELECT count(*) FROM pg_stat_activity + WHERE datname = current_database() AND state = 'active' + AND pid <> pg_backend_pid() AND query LIKE '%pg_sleep(30)%'"; + + let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: "SELECT pg_sleep(30);".to_string(), + path: None, + lock: None, + language: ScriptLang::Postgresql, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + tag: None, + })) + .arg( + "database", + json!({"host": "localhost", "port": 5432, "dbname": dbname, "user": "postgres", "password": "changeme"}), + ) + .run_until_complete_with(&db, false, port, |id| { + let (db, cancelled) = (db.clone(), cancelled.clone()); + async move { + tokio::spawn(async move { + while sqlx::query_scalar::<_, i64>(still_running) + .fetch_one(&db) + .await + .unwrap() + == 0 + { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + sqlx::query("UPDATE v2_job_queue SET canceled_by = 'test-user' WHERE id = $1") + .bind(id) + .execute(&db) + .await + .unwrap(); + cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + }); + } + }) + .await; + assert!(!job.success); + // Otherwise the job failed before its query ran, and there was nothing to stop. + assert!(cancelled.load(std::sync::atomic::Ordering::Relaxed)); + + let mut running = -1; + for _ in 0..50 { + running = sqlx::query_scalar::<_, i64>(still_running) + .fetch_one(&db) + .await?; + if running == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert_eq!(running, 0); + + clear_pg_cache().await; + Ok(()) +} + /// A worker alternating between two databases keeps a connection for each. #[sqlx::test(fixtures("base"))] #[serial(pg_cache)] diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index d0e7c6d652..5754b8c8c1 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -1245,18 +1245,51 @@ impl PgDatabase { } } + fn sslmode_requires_tls(&self) -> bool { + matches!( + self.sslmode.as_deref(), + Some("require") | Some("verify-ca") | Some("verify-full") + ) + } + + /// Asks the server to cancel what the connection behind `token` is running. Dropping a + /// connection does not: the server works on until it next writes to the client. + /// + /// The request goes out the way the connection was made: `token_auth` is set for one + /// authenticated with an access token, which is over TLS whatever the sslmode. + pub async fn cancel_query( + &self, + token: tokio_postgres::CancelToken, + token_auth: bool, + ) -> Result<(), error::Error> { + if token_auth || self.sslmode_requires_tls() { + let mut connector = native_tls::TlsConnector::builder(); + Self::configure_pg_tls_verification( + &mut connector, + self.sslmode.as_deref(), + self.root_certificate_pem.as_deref(), + self.accept_invalid_certs, + )?; + let connector = + postgres_native_tls::MakeTlsConnector::new(connector.build().map_err(to_anyhow)?); + token.cancel_query(connector).await.map_err(to_anyhow)?; + } else { + token + .cancel_query(tokio_postgres::tls::NoTls) + .await + .map_err(to_anyhow)?; + } + Ok(()) + } + async fn connect_inner( &self, ) -> Result<(tokio_postgres::Client, TokioPgConnection), error::Error> { use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; use tokio_postgres::tls::NoTls; - let ssl_mode_is_require = matches!( - self.sslmode.as_deref(), - Some("require") | Some("verify-ca") | Some("verify-full") - ); - if ssl_mode_is_require { + if self.sslmode_requires_tls() { tracing::info!("Creating new connection"); let mut connector = TlsConnector::builder(); Self::configure_pg_tls_verification( diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index ac6a2ae79b..aac7d549db 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -79,6 +79,19 @@ const PG_OWN_CONNECTION_STALL: Duration = Duration::from_secs(5); /// statements. A server that vanished without closing the socket never answers /// it, and waiting for TCP to notice takes the keepalive budget (~2 min). const PG_RESET_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +/// How long a closed connection gets to wind down. An idle one only has its Terminate to +/// send; one closed with results still arriving is cut off here. +const PG_CLOSE_GRACE: Duration = Duration::from_secs(2); +/// How long the connection of a failed job gets to end its query and reset its session. +/// Past this it is closed regardless. +const PG_CLOSE_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); +/// How long a failed job's last query gets to be answered before it is taken to be still +/// sending results, and cancelled. +const PG_UNREAD_RESULT_GRACE: Duration = Duration::from_millis(250); +/// Clears the session state a job can leave behind. Each statement is explained where a +/// cached connection is probed with it. +const PG_SESSION_RESET: &str = "RESET ALL; RESET SESSION AUTHORIZATION; UNLISTEN *; CLOSE ALL; \ + SELECT pg_advisory_unlock_all();"; struct PgConnectionCache { /// Least recently used first. @@ -145,13 +158,32 @@ struct PgConnection { key: String, scope: String, client: Client, - connection_task: tokio::task::JoinHandle<()>, + connection_task: Option>, last_used: Instant, } impl Drop for PgConnection { + /// The connection task sends the protocol Terminate and ends by itself once the client is + /// gone, right after this returns. Aborting it straight away closes the socket without + /// one, and a transaction pooler may then hand the server connection to its next client + /// as the job left it, inside a failed transaction. fn drop(&mut self) { - self.connection_task.abort(); + let Some(mut task) = self.connection_task.take() else { + return; + }; + match tokio::runtime::Handle::try_current() { + Ok(runtime) => { + runtime.spawn(async move { + if tokio::time::timeout(PG_CLOSE_GRACE, &mut task) + .await + .is_err() + { + task.abort(); + } + }); + } + Err(_) => task.abort(), + } } } @@ -248,6 +280,83 @@ impl Drop for PgConnectionLease { } } +/// Closes the connection of a job that failed or left a transaction open, in the background +/// so the job ends without waiting. A pooler in transaction mode hands the server connection +/// to its next client the way this one leaves it, and one closed inside a transaction it may +/// not get back at all, so the transaction is ended and the session reset first. +/// +/// `abandoned` is set for a query the timeout or a cancellation cut short. Closing does not +/// stop it, the server works on until it next writes to the client, so it is cancelled. So +/// is one still sending results the job stopped reading, which would otherwise be drained. +fn close_unclean_connection( + lease: PgConnectionLease, + database: PgDatabase, + token_auth: bool, + abandoned: bool, +) { + if lease.conn.is_none() { + return; + } + tokio::spawn(async move { + let client = lease.client(); + let cleanup = async { + let cancel = || database.cancel_query(client.cancel_token(), token_auth); + if abandoned { + cancel().await?; + } + // Answered once the server is done with the job's last query. A cancel request + // is only sent by then, not acted on, and a pooler drops one whose client left. + let settled = client.batch_execute(""); + tokio::pin!(settled); + let answer = if abandoned { + settled.await + } else { + match tokio::time::timeout(PG_UNREAD_RESULT_GRACE, &mut settled).await { + Ok(answer) => answer, + Err(_) => { + cancel().await?; + settled.await + } + } + }; + // A cancel request that found the query already over lands on what comes next. + let cancelled = |e: &tokio_postgres::Error| { + e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED) + }; + answer + .or_else(|e| if cancelled(&e) { Ok(()) } else { Err(e) }) + .map_err(to_anyhow)?; + let reset = || async { + let in_transaction = + client.transaction_status() != tokio_postgres::TransactionStatus::Idle; + let rollback = if in_transaction { "ROLLBACK; " } else { "" }; + client + .batch_execute(&format!("{rollback}{PG_SESSION_RESET}")) + .await + }; + let mut done = reset().await; + if done.as_ref().is_err_and(cancelled) { + done = reset().await; + } + done.map_err(to_anyhow)?; + Ok::<_, Error>(()) + }; + match tokio::time::timeout(PG_CLOSE_CLEANUP_TIMEOUT, cleanup).await { + Ok(Ok(())) => {} + // A connection that failed the job has nothing left to clean up. + Ok(Err(e)) if abandoned => { + tracing::warn!("could not cancel an abandoned postgres query: {e}") + } + Ok(Err(_)) => {} + Err(_) => tracing::warn!( + "a postgres connection did not settle within {}s of its job ending, closing it", + PG_CLOSE_CLEANUP_TIMEOUT.as_secs() + ), + } + drop(lease); + }); +} + async fn sweep_idle_pg_connections() { loop { tokio::time::sleep(Duration::from_secs(1)).await; @@ -1248,13 +1357,7 @@ pub async fn do_postgresql( // // Doubles as a liveness probe — if the connection is broken any // statement in the chain fails, or it never answers, and we replace it. - let reset = lease.client().batch_execute( - "RESET ALL; \ - RESET SESSION AUTHORIZATION; \ - UNLISTEN *; \ - CLOSE ALL; \ - SELECT pg_advisory_unlock_all();", - ); + let reset = lease.client().batch_execute(PG_SESSION_RESET); if matches!( tokio::time::timeout(PG_RESET_PROBE_TIMEOUT, reset).await, Ok(Ok(())) @@ -1287,7 +1390,7 @@ pub async fn do_postgresql( key: database_string.clone(), scope: lease.scope.clone(), client, - connection_task, + connection_task: Some(connection_task), last_used: Instant::now(), }); } @@ -1451,6 +1554,14 @@ pub async fn do_postgresql( result_f.await } }; + // Still unset once the job is over: the query was abandoned mid-flight, by the timeout or + // a cancellation, rather than ended by an error of its own. + let query_ended = std::sync::atomic::AtomicBool::new(false); + let result_f = async { + let result = result_f.await; + query_ended.store(true, Ordering::Relaxed); + result + }; let result = if run_inline { result_f.await @@ -1468,15 +1579,26 @@ pub async fn do_postgresql( Box::pin(futures::stream::once(async { 0 })), ) .await - } - .map_err(|e| map_s3object_jsonb_overflow(e, had_s3object_input))?; + }; + let result = match result { + Ok(result) => result, + Err(e) => { + close_unclean_connection( + lease, + database, + auth_mode != PgAuthMode::Password, + !query_ended.load(Ordering::Relaxed), + ); + return Err(map_s3object_jsonb_overflow(e, had_s3object_input)); + } + }; *mem_peak = size.load(Ordering::Relaxed) as i32; // A transaction the script left open would otherwise carry over into the - // next job that reuses the connection. Closing the connection rolls it - // back, as it always has without the cache. The status comes with the last - // reply, so a script that ends cleanly pays nothing for this check. + // next job that reuses the connection, so it is rolled back and the connection + // closed. The status comes with the last reply, so a script that ends cleanly + // pays nothing for this check. if lease.client().transaction_status() != tokio_postgres::TransactionStatus::Idle { if !run_inline { windmill_queue::append_logs( @@ -1488,14 +1610,15 @@ pub async fn do_postgresql( ) .await; } + close_unclean_connection(lease, database, auth_mode != PgAuthMode::Password, false); } else { // Keep the connection only after a query that read its results to the end. // An error may have stopped reading early (the result cap, a decode failure), // as a timeout, a dropped future or a first-row collection do: the server // keeps sending, and the next job's reset probe would wait on it. lease.keep = !*CLOUD_HOSTED && !collection_strategy.collect_first_row_only(); + drop(lease); } - drop(lease); *mem_peak = (result.get().len() / 1000) as i32; // And then check that we got back the same string we sent over. From 14d2995d76453016f9b709f89b60196c962d27b1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 4 Oct 2026 14:11:48 +0200 Subject: [PATCH 028/174] chore(main): release 1.823.0 (#11503) * chore(main): release 1.823.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 17 ++ backend/Cargo.lock | 168 +++++++++--------- 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, 148 insertions(+), 131 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6a0fae5efe..64ea1104fc 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.822.0" + ".": "1.823.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 849ef9bae9..3984a9b3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.823.0](https://github.com/windmill-labs/windmill/compare/v1.822.0...v1.823.0) (2026-10-04) + + +### Features + +* redesign the service logs page ([#11506](https://github.com/windmill-labs/windmill/issues/11506)) ([0f65df2](https://github.com/windmill-labs/windmill/commit/0f65df24d2cabd9d8daa71c103969e6392e37b4f)) + + +### Bug Fixes + +* apply the pg keepalive settings to the postgres trigger connection ([#11512](https://github.com/windmill-labs/windmill/issues/11512)) ([33f6de0](https://github.com/windmill-labs/windmill/commit/33f6de0cd20f45ff56770f946a4731e5b13bf677)) +* bound the datatable migration lock wait and reap a dead holder ([#11510](https://github.com/windmill-labs/windmill/issues/11510)) ([711e08c](https://github.com/windmill-labs/windmill/commit/711e08c1d20a326142235c83d8805b7990bf6e6d)) +* bound the pg cached-connection reset probe so a vanished server fails fast ([#11505](https://github.com/windmill-labs/windmill/issues/11505)) ([d4a423f](https://github.com/windmill-labs/windmill/commit/d4a423f567e1fb5e21016a2be61d858ab61a4d4f)) +* cancel an abandoned pg query and close failed connections cleanly ([#11511](https://github.com/windmill-labs/windmill/issues/11511)) ([26856b8](https://github.com/windmill-labs/windmill/commit/26856b8061c28eade191c7d85a32b74ca44c4fea)) +* delete a flow chat turn with its flow run, not its step jobs ([#11477](https://github.com/windmill-labs/windmill/issues/11477)) ([35ec16d](https://github.com/windmill-labs/windmill/commit/35ec16d12728531ab90632116913e98f80d1cc05)) +* drop a fork's datatable database despite connected sessions ([#11509](https://github.com/windmill-labs/windmill/issues/11509)) ([fa419af](https://github.com/windmill-labs/windmill/commit/fa419af9b3f7d65a607bb8ea2bcea58223f743c4)) + ## [1.822.0](https://github.com/windmill-labs/windmill/compare/v1.821.0...v1.822.0) (2026-10-03) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index dcaf000d1a..5512a430d2 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -828,13 +828,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -7856,9 +7856,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.822.0" +version = "1.823.0" dependencies = [ "async-stream", "async-trait", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "argon2", @@ -15105,7 +15105,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15145,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15171,7 +15171,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.822.0" +version = "1.823.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15198,7 +15198,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15220,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15259,7 +15259,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15303,7 +15303,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-nats", @@ -15353,7 +15353,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15378,7 +15378,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15406,7 +15406,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15428,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15448,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15490,7 +15490,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.822.0" +version = "1.823.0" dependencies = [ "lazy_static", "serde", @@ -15531,7 +15531,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.822.0" +version = "1.823.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15555,7 +15555,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.822.0" +version = "1.823.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15608,7 +15608,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.822.0" +version = "1.823.0" dependencies = [ "chrono", "lazy_static", @@ -15622,7 +15622,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15641,7 +15641,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.822.0" +version = "1.823.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15755,7 +15755,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.822.0" +version = "1.823.0" dependencies = [ "chrono", "futures", @@ -15775,7 +15775,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.822.0" +version = "1.823.0" dependencies = [ "regex", "serde", @@ -15792,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15819,7 +15819,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "futures", @@ -15836,7 +15836,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.822.0" +version = "1.823.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15852,7 +15852,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -15873,7 +15873,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -15904,7 +15904,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "arc-swap", @@ -15929,7 +15929,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-stream", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "futures", @@ -15983,7 +15983,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.822.0" +version = "1.823.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15992,7 +15992,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -16004,7 +16004,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde_json", @@ -16016,7 +16016,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "gosyn", @@ -16028,7 +16028,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -16040,7 +16040,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde_json", @@ -16052,7 +16052,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "nu-parser", @@ -16063,7 +16063,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16086,7 +16086,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-recursion", @@ -16119,7 +16119,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde_json", @@ -16131,7 +16131,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -16145,7 +16145,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16162,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -16175,7 +16175,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde", @@ -16187,7 +16187,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -16205,7 +16205,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16221,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16237,7 +16237,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -16251,7 +16251,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-recursion", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "const_format", @@ -16330,7 +16330,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.822.0" +version = "1.823.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16341,7 +16341,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-recursion", @@ -16376,7 +16376,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16400,7 +16400,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16433,7 +16433,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16460,7 +16460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16493,7 +16493,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16513,7 +16513,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16547,7 +16547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16584,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16607,7 +16607,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16631,7 +16631,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-nats", @@ -16655,7 +16655,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16718,7 +16718,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-trait", @@ -16743,7 +16743,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16762,7 +16762,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-once-cell", @@ -16881,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.822.0" +version = "1.823.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index af0d8b3a14..d420ef30df 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.822.0" +version = "1.823.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.822.0" +version = "1.823.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 91d488fcf9..1c506cb136 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.822.0" +version = "1.823.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.822.0" +version = "1.823.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.822.0" +version = "1.823.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.822.0" +version = "1.823.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index ae09ba533d..c24a77b53d 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.822.0" +version = "1.823.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ce35db405d..061cdb69a6 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.822.0 + version: 1.823.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 60215e6249..5425ce14ba 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.822.0"; +export const VERSION = "v1.823.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 652572d7a0..8e842e24cb 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.822.0", + "version": "1.823.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.822.0", + "version": "1.823.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 7c91c0b548..c0f991b07a 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.822.0", + "version": "1.823.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 4f2a481ecb..ac342792d7 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.822.0"; +export const VERSION = "1.823.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aa86f89cea..772287b13b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.822.0", + "version": "1.823.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.822.0", + "version": "1.823.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index ba1a21a309..e5b25e3db7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.822.0", + "version": "1.823.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 017aa4852f..a3e3d64a99 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.822.0" +wmill = ">=1.823.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index c1902b8d48..fc6ec4fb05 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.822.0 + version: 1.823.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 73c66b2cd0..d268971f2a 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.822.0' + ModuleVersion = '1.823.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index c5afbe0d3d..99c18181e1 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.822.0" +version = "1.823.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 e08fa56e7c..74ce5a2491 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.822.0", + "version": "1.823.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 ab3fa68f56..43a13bc67b 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.822.0", + "version": "1.823.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 0ec12c5074..3022a31085 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.822.0 +1.823.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 2d69813554..20df8c8c28 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.822.0", + "version": "1.823.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.822.0", + "version": "1.823.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 ade5fc4c0b..d647edfd15 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.822.0", + "version": "1.823.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 289d3c3941c2d9997bf0476bf9bfdfd948542c55 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 5 Oct 2026 08:05:25 +0200 Subject: [PATCH 029/174] fix: stream the ai chat compaction request (#11514) * fix: stream the ai chat compaction request instead of waiting on it Co-Authored-By: Claude Opus 5.5 * fix: reject a compaction stream stopped midway instead of keeping partial text Co-Authored-By: Claude Opus 5.5 * fix: reject a compaction stream that ends without a finish reason Co-Authored-By: Claude Opus 5.5 * test: cover a completed responses stream in the streamed completion helper Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../copilot/chat/AIChatManager.svelte.ts | 10 +-- .../copilot/chat/AIChatManager.test.ts | 52 +++++------ .../copilot/chat/openai-responses.ts | 16 ++++ .../copilot/lib.anthropicRouting.test.ts | 87 ++++++++++++++++++- frontend/src/lib/components/copilot/lib.ts | 71 ++++++++++++++- 5 files changed, 200 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 7bf9685e87..8b0c346244 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -52,7 +52,7 @@ import { loadApiTools } from './api/apiTools' import { prepareScriptUserMessage } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { workspaceAIClients, getNonStreamingCompletion } from '../lib' +import { workspaceAIClients, getStreamedCompletionText } from '../lib' import { logFeatureUsage } from '$lib/utils/featureUsage' import { modelSupportsVision } from '../modelConfig' import { getEffectiveModelContextWindow } from '../modelConfig' @@ -1587,10 +1587,8 @@ export class AIChatManager implements ChatViewHost { this.compacting = true try { // Cap the summarizer's output at the budget already reserved for the - // summary. Without a cap the model's default max_tokens applies, and the - // Anthropic SDK rejects non-streaming requests whose max_tokens implies - // >10 minutes of generation (~21k tokens) before anything is sent. - const raw = await getNonStreamingCompletion( + // summary: without a cap the model's default max_tokens applies. + const raw = await getStreamedCompletionText( [ // Strip image blobs from the summarizer input — the summary text stands in // for them, so re-sending base64 to the summarizer only wastes tokens. @@ -1600,7 +1598,7 @@ export class AIChatManager implements ChatViewHost { abortController, { maxTokensCap: SUMMARY_OUTPUT_RESERVE_TOKENS } ) - const formatted = formatCompactSummary(raw ?? '') + const formatted = formatCompactSummary(raw) if (!formatted) { return 'empty' } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 97a1afb2ff..dbb3ac51e3 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -33,7 +33,7 @@ const mocks = vi.hoisted(() => ({ sendUserToast: vi.fn(), getOpenaiClient: vi.fn(), getAnthropicClient: vi.fn(), - getNonStreamingCompletion: vi.fn(), + getStreamedCompletionText: vi.fn(), runChatLoop: vi.fn(), listResource: vi.fn(), getJob: vi.fn(), @@ -143,7 +143,7 @@ vi.mock('../lib', () => ({ getOpenaiClient: mocks.getOpenaiClient, getAnthropicClient: mocks.getAnthropicClient }, - getNonStreamingCompletion: mocks.getNonStreamingCompletion, + getStreamedCompletionText: mocks.getStreamedCompletionText, providerSupportsWebSearch: (provider: string) => provider === 'openai' || provider === 'anthropic' })) @@ -3350,7 +3350,7 @@ describe('AIChatManager context compaction', () => { it('summarizes the older prefix and keeps the recent tail verbatim', async () => { mocks.getCurrentModel.mockReturnValue(gpt4oModel) mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) - mocks.getNonStreamingCompletion.mockResolvedValue( + mocks.getStreamedCompletionText.mockResolvedValue( 'scratchpadSUMMARY TEXT' ) const manager = new AIChatManager() @@ -3360,8 +3360,8 @@ describe('AIChatManager context compaction', () => { // The prefix (the four OLD messages) was sent to the summarizer, followed // by the summary-instruction user message. - expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) - const summaryReq = mocks.getNonStreamingCompletion.mock.calls[0][0] + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(1) + const summaryReq = mocks.getStreamedCompletionText.mock.calls[0][0] expect(summaryReq).toHaveLength(5) expect(summaryReq[0].content).toContain('OLD1') expect(summaryReq[3].content).toContain('OLD4') @@ -3392,7 +3392,7 @@ describe('AIChatManager context compaction', () => { it('carries folded-away message files on the summary', async () => { mocks.getCurrentModel.mockReturnValue(gpt4oModel) mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) - mocks.getNonStreamingCompletion.mockResolvedValue( + mocks.getStreamedCompletionText.mockResolvedValue( 'sSUM' ) const manager = new AIChatManager() @@ -3423,7 +3423,7 @@ describe('AIChatManager context compaction', () => { it('never lands the tail boundary on a screenshot follow-up that has no display counterpart', async () => { mocks.getCurrentModel.mockReturnValue(gpt4oModel) mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) - mocks.getNonStreamingCompletion.mockResolvedValue('SUMMARY TEXT') + mocks.getStreamedCompletionText.mockResolvedValue('SUMMARY TEXT') const manager = new AIChatManager() manager.messages = [ { role: 'user', content: 'OLD1' + 'a'.repeat(100_000) }, @@ -3471,7 +3471,7 @@ describe('AIChatManager context compaction', () => { it('falls back to drop-oldest when summarization fails', async () => { mocks.getCurrentModel.mockReturnValue(gpt4oModel) mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) - mocks.getNonStreamingCompletion.mockRejectedValue(new Error('summary boom')) + mocks.getStreamedCompletionText.mockRejectedValue(new Error('summary boom')) const manager = new AIChatManager() seedForSummary(manager) @@ -3479,7 +3479,7 @@ describe('AIChatManager context compaction', () => { // Summarization was attempted, then the request still went out — via // drop-oldest, so no summary boundary anywhere. - expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(1) expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) const sent = mocks.runChatLoop.mock.calls[0][0].messages expect(sent[0].content).not.toContain('continued from a previous conversation') @@ -3501,7 +3501,7 @@ describe('AIChatManager context compaction', () => { await manager.sendRequest() // A two-message prefix isn't worth a summary round-trip. - expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled() + expect(mocks.getStreamedCompletionText).not.toHaveBeenCalled() expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false) }) @@ -3510,7 +3510,7 @@ describe('AIChatManager context compaction', () => { mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) // The user hits Stop while the summary request is in flight: it aborts the // turn's controller and rejects. - mocks.getNonStreamingCompletion.mockImplementation(async (_msgs: any, ac: AbortController) => { + mocks.getStreamedCompletionText.mockImplementation(async (_msgs: any, ac: AbortController) => { ac.abort('user_cancelled') throw new Error('aborted') }) @@ -3530,7 +3530,7 @@ describe('AIChatManager context compaction', () => { // Summarization was attempted and aborted, but the abort must NOT trigger a // destructive drop-oldest fallback: the full prefix survives and the unsent // turn is rolled back to the pre-send history (the head pair is still there). - expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(1) expect(manager.messages).toHaveLength(6) expect(manager.messages[0].content).toContain('OLD1') expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false) @@ -3565,7 +3565,7 @@ describe('AIChatManager manual compaction', () => { } it('folds the whole history into a single summary boundary, keeping nothing verbatim', async () => { - mocks.getNonStreamingCompletion.mockResolvedValue('MANUAL SUMMARY') + mocks.getStreamedCompletionText.mockResolvedValue('MANUAL SUMMARY') const manager = new AIChatManager() seedExchange(manager) manager.contextUsage = 123 @@ -3574,15 +3574,15 @@ describe('AIChatManager manual compaction', () => { await manager.compactManually() // The summarizer saw the entire history, then the summary instruction. - expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) - const summaryReq = mocks.getNonStreamingCompletion.mock.calls[0][0] + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(1) + const summaryReq = mocks.getStreamedCompletionText.mock.calls[0][0] expect(summaryReq).toHaveLength(5) expect(summaryReq[0].content).toBe('q1') expect(summaryReq[3].content).toBe('a2') expect(summaryReq[4].content).toContain('detailed summary') - // The summarizer's output must stay capped: without it the model default - // applies and the Anthropic SDK rejects the non-streaming call pre-flight. - expect(mocks.getNonStreamingCompletion.mock.calls[0][2]).toEqual({ maxTokensCap: 8000 }) + // The summarizer's output must stay capped at the budget reserved for the + // summary: without it the model default applies. + expect(mocks.getStreamedCompletionText.mock.calls[0][2]).toEqual({ maxTokensCap: 8000 }) // Nothing kept verbatim: messages collapse to just the summary user message. expect(manager.messages).toHaveLength(1) @@ -3608,13 +3608,13 @@ describe('AIChatManager manual compaction', () => { await manager.compactManually() - expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled() + expect(mocks.getStreamedCompletionText).not.toHaveBeenCalled() expect(mocks.sendUserToast).toHaveBeenCalledWith('Nothing to compact yet.') expect(manager.messages).toHaveLength(1) }) it('leaves history untouched when the user stops mid-summary', async () => { - mocks.getNonStreamingCompletion.mockImplementation(async (_msgs: any, ac: AbortController) => { + mocks.getStreamedCompletionText.mockImplementation(async (_msgs: any, ac: AbortController) => { ac.abort('user_cancelled') throw new Error('aborted') }) @@ -3631,7 +3631,7 @@ describe('AIChatManager manual compaction', () => { }) it('routes the /compact session command to manual compaction instead of the model', async () => { - mocks.getNonStreamingCompletion.mockResolvedValue('VIA COMMAND') + mocks.getStreamedCompletionText.mockResolvedValue('VIA COMMAND') const manager = new AIChatManager() manager.isSessionChat = true seedExchange(manager) @@ -3643,13 +3643,13 @@ describe('AIChatManager manual compaction', () => { expect(sent).toBe(true) expect(mocks.runChatLoop).not.toHaveBeenCalled() // ...it ran the summarizer and compacted in place, clearing the composer. - expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(1) expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'VIA COMMAND' }) expect(manager.instructions).toBe('') }) it('auto-sends a message queued while compaction was running', async () => { - mocks.getNonStreamingCompletion.mockResolvedValue('S') + mocks.getStreamedCompletionText.mockResolvedValue('S') mocks.runChatLoop.mockImplementation(async (config: any) => { const message = { role: 'assistant' as const, content: 'done' } config.addedMessages?.push(message) @@ -3669,7 +3669,7 @@ describe('AIChatManager manual compaction', () => { await manager.compactManually() // Compaction ran once, then the queued message went out as a real turn. - expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(1) expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) const sent = mocks.runChatLoop.mock.calls[0][0].messages expect(sent[sent.length - 1].content).toContain('follow-up question') @@ -3687,7 +3687,7 @@ describe('AIChatManager manual compaction', () => { // without ever reaching the model... expect(sent).toBe(true) expect(mocks.runChatLoop).not.toHaveBeenCalled() - expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled() + expect(mocks.getStreamedCompletionText).not.toHaveBeenCalled() // ...it reset the conversation and cleared the composer. expect(manager.displayMessages).toEqual([]) expect(manager.messages).toEqual([]) @@ -3758,7 +3758,7 @@ describe('AIChatManager manual compaction', () => { // Without the session-chat command surface, /compact is a normal message. expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) - expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled() + expect(mocks.getStreamedCompletionText).not.toHaveBeenCalled() }) it('shadows a selected skill that collides with a built-in command', () => { diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index ebaa5b1c38..3a839f4bad 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -328,6 +328,7 @@ export async function* getOpenAIResponsesCompletionStream( forceModelProvider?: AIProviderModel openaiClient?: OpenAI reasoningEffort?: string + maxTokensCap?: number } ): AsyncGenerator { const { provider, config } = getProviderAndCompletionConfig({ @@ -335,6 +336,7 @@ export async function* getOpenAIResponsesCompletionStream( stream: true, tools, forceModelProvider: options?.forceModelProvider, + maxTokensCap: options?.maxTokensCap, reasoningEffort: options?.reasoningEffort }) const { instructions, input } = convertMessagesToResponsesInput(messages) @@ -383,6 +385,20 @@ export async function* getOpenAIResponsesCompletionStream( } ] } as OpenAI.Chat.Completions.ChatCompletionChunk + } else if (event.type === 'response.completed' || event.type === 'response.incomplete') { + yield { + id: 'chatcmpl-' + Date.now(), + object: 'chat.completion.chunk', + created: Date.now(), + model: responsesConfig.model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: event.type === 'response.completed' ? 'stop' : 'length' + } + ] + } as OpenAI.Chat.Completions.ChatCompletionChunk } } } diff --git a/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts b/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts index 4bffcd80ac..52d58863c6 100644 --- a/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts +++ b/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts @@ -99,6 +99,7 @@ async function setupClients() { textDelta('Hel'), { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{' } }, textDelta('lo'), + { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, { type: 'message_stop' } ]) ) @@ -175,8 +176,9 @@ describe('Anthropic Messages API routing', () => { } expect(anthropicStream).toHaveBeenCalledTimes(1) - // only the two text deltas surface; message_start/stop and input_json are dropped - expect(chunks).toBe(2) + // the two text deltas and the stop reason surface; message_start/stop and + // input_json are dropped + expect(chunks).toBe(3) expect(text).toBe('Hello') }) @@ -228,6 +230,87 @@ describe('Anthropic Messages API routing', () => { expect(METADATA_MAX_TOKENS).toBeLessThanOrEqual(21333) }) + it('getStreamedCompletionText streams the capped request and falls back off the Responses API', async () => { + const { getStreamedCompletionText, workspaceAIClients } = await import('./lib') + + h.currentModel = { provider: 'anthropic', model: 'claude-sonnet-4-6' } + expect( + await getStreamedCompletionText(messages, new AbortController(), { maxTokensCap: 8000 }) + ).toBe('Hello') + expect(anthropicCreate).not.toHaveBeenCalled() + expect(anthropicStream.mock.calls[0][0].max_tokens).toBe(8000) + + h.currentModel = { provider: 'openai', model: 'gpt-4o' } + const completedResponses = vi + .fn() + .mockReturnValue( + streamOf([ + { type: 'response.created' }, + { type: 'response.output_text.delta', delta: 'responses ' }, + { type: 'response.output_text.delta', delta: 'text' }, + { type: 'response.completed' } + ]) + ) + vi.spyOn(workspaceAIClients, 'getOpenaiClient').mockReturnValue({ + chat: { completions: { create: openaiCreate } }, + responses: { stream: completedResponses } + } as any) + expect(await getStreamedCompletionText(messages, new AbortController())).toBe('responses text') + expect(openaiCreate).not.toHaveBeenCalled() + + // The Responses stream fails on iteration, not on creation. + const responsesStream = vi.fn().mockReturnValue( + (async function* () { + throw new Error('responses api not served') + })() + ) + openaiCreate.mockResolvedValue( + streamOf([ + { choices: [{ delta: { content: 'chat ' } }] }, + { choices: [{ delta: { content: 'text' } }] }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + { choices: [], usage: {} } + ]) + ) + vi.spyOn(workspaceAIClients, 'getOpenaiClient').mockReturnValue({ + chat: { completions: { create: openaiCreate } }, + responses: { stream: responsesStream } + } as any) + vi.spyOn(console, 'error').mockImplementation(() => {}) + h.currentModel = { provider: 'openai', model: 'gpt-4o' } + + expect( + await getStreamedCompletionText(messages, new AbortController(), { maxTokensCap: 8000 }) + ).toBe('chat text') + expect(responsesStream.mock.calls[0][0].max_output_tokens).toBe(8000) + expect(openaiCreate.mock.calls[0][0]).toMatchObject({ stream: true, max_tokens: 8000 }) + }) + + it('getStreamedCompletionText rejects when the stream is cut or stopped midway', async () => { + const { getStreamedCompletionText } = await import('./lib') + h.currentModel = { provider: 'deepseek', model: 'deepseek-chat' } + + // A hop closing the response early ends the iteration cleanly, with no + // finish reason. + openaiCreate.mockResolvedValue(streamOf([{ choices: [{ delta: { content: 'partial' } }] }])) + await expect(getStreamedCompletionText(messages, new AbortController())).rejects.toThrow( + 'ended before the model finished' + ) + + const abortController = new AbortController() + // The OpenAI SDK swallows the abort and just ends the iteration. + openaiCreate.mockResolvedValue( + (async function* () { + yield { choices: [{ delta: { content: 'partial' } }] } + abortController.abort('user_cancelled') + })() + ) + + await expect(getStreamedCompletionText(messages, abortController)).rejects.toBe( + 'user_cancelled' + ) + }) + it('caps max_output_tokens for metadata completions on the OpenAI Responses path', async () => { const { getNonStreamingCompletion, getNonStreamingMetadataCompletion, METADATA_MAX_TOKENS } = await import('./lib') diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 40f1dee6a6..dc420be2cc 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -746,6 +746,20 @@ function getAnthropicStreamingCompletion({ model: params.modelProvider.model, choices: [{ index: 0, delta: { content: event.delta.text }, finish_reason: null }] } + } else if (event.type === 'message_delta' && event.delta.stop_reason) { + yield { + id: '', + object: 'chat.completion.chunk', + created: 0, + model: params.modelProvider.model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: event.delta.stop_reason === 'max_tokens' ? 'length' : 'stop' + } + ] + } } } } @@ -1158,12 +1172,18 @@ export async function getCompletion( openaiClient?: OpenAI reasoningEffort?: string promptCaching?: boolean + maxTokensCap?: number } ): Promise> { const modelProvider = options?.forceModelProvider ?? getCurrentModel() if (usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)) { - return getAnthropicStreamingCompletion({ messages, modelProvider, abortController }) + return getAnthropicStreamingCompletion({ + messages, + modelProvider, + abortController, + maxTokensCap: options?.maxTokensCap + }) } const { provider, config } = getProviderAndCompletionConfig({ @@ -1171,6 +1191,7 @@ export async function getCompletion( stream: true, tools, forceModelProvider: options?.forceModelProvider, + maxTokensCap: options?.maxTokensCap, promptCaching: options?.promptCaching, reasoningEffort: options?.reasoningEffort }) @@ -1181,7 +1202,8 @@ export async function getCompletion( const stream = getOpenAIResponsesCompletionStream(messages, abortController, tools, { forceModelProvider: options?.forceModelProvider, openaiClient: options?.openaiClient, - reasoningEffort: options?.reasoningEffort + reasoningEffort: options?.reasoningEffort, + maxTokensCap: options?.maxTokensCap }) as any return stream } catch (error) { @@ -1223,6 +1245,51 @@ export async function getCompletion( return completion } +/** + * Streams a completion and returns its whole text. For a generation that can run + * for minutes: a non-streaming request sends no byte until the model is done, so + * an idle timeout on any hop between the browser and the provider cuts it. + */ +export async function getStreamedCompletionText( + messages: ChatCompletionMessageParam[], + abortController: AbortController, + options?: { maxTokensCap?: number } +): Promise { + const drain = async (forceCompletions: boolean) => { + const stream = await getCompletion(messages, abortController, undefined, { + forceCompletions, + maxTokensCap: options?.maxTokensCap + }) + let text = '' + let finished = false + for await (const chunk of stream) { + text += getResponseFromEvent(chunk) + finished ||= !!chunk.choices?.[0]?.finish_reason + } + // The OpenAI SDK ends an aborted stream, and one closed early by a hop in + // between, without throwing: partial text must not pass for the whole + // completion. + abortController.signal.throwIfAborted() + if (!finished) { + throw new Error('The completion stream ended before the model finished') + } + return text + } + + try { + return await drain(false) + } catch (error) { + // The Responses API stream only fails once iterated, so the fallback to + // chat completions for a deployment that doesn't serve it lives here. + const { provider } = getCurrentModel() + if (abortController.signal.aborted || (provider !== 'openai' && provider !== 'azure_openai')) { + throw error + } + console.error('Error using Responses API:', error) + return drain(true) + } +} + function extractFirstJSON(str: string) { let depth = 0, i = 0 From 3fff1bd3dcedce22c58d988ad2e725e3d4e96e0e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 5 Oct 2026 08:20:52 +0200 Subject: [PATCH 030/174] fix: python results with a KeyError-raising __getattr__ fail the job (#11517) * fix: python results with a KeyError-raising __getattr__ no longer fail Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the stream probe on the instance so enum results still serialize Co-Authored-By: Claude Opus 5.5 (1M context) * fix: probe python results through class dicts, never their __getattr__ Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/python_jobs.rs | 22 ++++++++++++++++++ .../windmill-worker/src/python_executor.rs | 12 +++++++--- python-client/wmill/wmill/s3_types.py | 23 +++++++++++++------ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 33961e465f..4236599aba 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -496,6 +496,28 @@ async def main(x: int): ); } + // `hasattr` does not swallow the KeyError this `__getattr__` raises; released + // `wmill` clients ship an `S3Object` built this way. + #[test] + fn test_python_execd_result_with_keyerror_getattr() { + let script = r#" +class AttrDict(dict): + def __getattr__(self, attr): + return self[attr] + +def main(x: str): + return AttrDict(s3=x) +"#; + let results = run_py_raw_protocol_test( + &[("f/test/attr_dict", script)], + vec![ProtocolCmd::Execd { args: serde_json::json!({"x": "a/b.txt"}) }], + ); + assert_eq!( + results, + vec![DedicatedWorkerResult::Success(serde_json::json!({"s3": "a/b.txt"}))] + ); + } + // ==================== Argument Transformation Tests ==================== #[test] diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 6b24b93e77..3fce0fb938 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -860,7 +860,7 @@ pub async fn handle_python_job( if v == '': del pre_args[k] _pre_result = inner_script.preprocessor(**pre_args) - if hasattr(_pre_result, '__await__'): + if any('__await__' in vars(c) for c in type(_pre_result).__mro__): import asyncio _pre_result = asyncio.run(_pre_result) kwargs = _pre_result if _pre_result is not None else {{}} @@ -981,7 +981,7 @@ try: raise ValueError("{main_override} function is missing") res = _await(inner_script.{main_override}(**args)) typ = type(res) - if hasattr(res, '__iter__') and not isinstance(res, (str, dict, list, bytes, tuple, set, frozenset, range, memoryview, bytearray)) and typ.__name__ != 'DataFrame': + if _defines(res, '__iter__') and not isinstance(res, (str, dict, list, bytes, tuple, set, frozenset, range, memoryview, bytearray)) and typ.__name__ != 'DataFrame': for chunk in res: print("WM_STREAM: " + chunk.replace('\n', '\\n')) res = None @@ -3340,10 +3340,16 @@ This is not normal behavior, please make sure all workers have enough memory.\n /// loop-per-call would break them from the second job on. A loop the script /// set while importing (`asyncio.set_event_loop`) is reused. asyncio is /// imported lazily so sync jobs don't pay its import time. +/// `_defines` reads the class dicts instead of calling `hasattr`: a result is +/// arbitrary user data, and `hasattr` runs its `__getattr__` (or its +/// metaclass's), which may raise something other than AttributeError or +/// answer every name. const PY_AWAIT_HELPER: &str = r#"_loop = None +def _defines(r, name): + return any(name in vars(c) for c in type(r).__mro__) def _await(r): global _loop - if not hasattr(r, '__await__'): + if not _defines(r, '__await__'): return r import asyncio if _loop is None or _loop.is_closed(): diff --git a/python-client/wmill/wmill/s3_types.py b/python-client/wmill/wmill/s3_types.py index cd6311692d..1b43b65972 100644 --- a/python-client/wmill/wmill/s3_types.py +++ b/python-client/wmill/wmill/s3_types.py @@ -1,6 +1,15 @@ from typing import Optional +def _item_as_attr(d: dict, attr: str): + # AttributeError, not KeyError: hasattr, copy and pickle probe optional + # attributes and only treat AttributeError as "absent". + try: + return d[attr] + except KeyError: + raise AttributeError(attr) from None + + class S3Object(dict): """S3 file reference with file key, optional storage identifier, and presigned token.""" s3: str @@ -8,7 +17,7 @@ class S3Object(dict): presigned: Optional[str] def __getattr__(self, attr): - return self[attr] + return _item_as_attr(self, attr) class S3FsClientKwargs(dict): @@ -16,7 +25,7 @@ class S3FsClientKwargs(dict): region_name: str def __getattr__(self, attr): - return self[attr] + return _item_as_attr(self, attr) class S3FsArgs(dict): @@ -29,7 +38,7 @@ class S3FsArgs(dict): client_kwargs: S3FsClientKwargs def __getattr__(self, attr): - return self[attr] + return _item_as_attr(self, attr) class StorageOptions(dict): @@ -41,7 +50,7 @@ class StorageOptions(dict): aws_allow_http: str def __getattr__(self, attr): - return self[attr] + return _item_as_attr(self, attr) class PolarsConnectionSettings(dict): @@ -50,7 +59,7 @@ class PolarsConnectionSettings(dict): storage_options: StorageOptions def __getattr__(self, attr): - return self[attr] + return _item_as_attr(self, attr) class Boto3ConnectionSettings(dict): @@ -63,7 +72,7 @@ class Boto3ConnectionSettings(dict): aws_session_token: Optional[str] def __getattr__(self, attr): - return self[attr] + return _item_as_attr(self, attr) class DuckDbConnectionSettings(dict): @@ -71,4 +80,4 @@ class DuckDbConnectionSettings(dict): connection_settings_str: str def __getattr__(self, attr): - return self[attr] + return _item_as_attr(self, attr) From 4096c3da74138e3476c2cd9996cbb455aaf8e926 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 5 Oct 2026 08:24:51 +0200 Subject: [PATCH 031/174] chore(main): release 1.823.1 (#11516) * chore(main): release 1.823.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 + backend/Cargo.lock | 162 +++++++++--------- 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, 136 insertions(+), 128 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 64ea1104fc..0f25f57894 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.823.0" + ".": "1.823.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3984a9b3c5..cddb164d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.823.1](https://github.com/windmill-labs/windmill/compare/v1.823.0...v1.823.1) (2026-10-05) + + +### Bug Fixes + +* python results with a KeyError-raising __getattr__ fail the job ([#11517](https://github.com/windmill-labs/windmill/issues/11517)) ([3fff1bd](https://github.com/windmill-labs/windmill/commit/3fff1bd3dcedce22c58d988ad2e725e3d4e96e0e)) +* stream the ai chat compaction request ([#11514](https://github.com/windmill-labs/windmill/issues/11514)) ([289d3c3](https://github.com/windmill-labs/windmill/commit/289d3c3941c2d9997bf0476bf9bfdfd948542c55)) + ## [1.823.0](https://github.com/windmill-labs/windmill/compare/v1.822.0...v1.823.0) (2026-10-04) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5512a430d2..2b17505c10 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14066,9 +14066,9 @@ dependencies = [ [[package]] name = "twox-hash" -version = "2.1.4" +version = "2.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" [[package]] name = "typed-path" @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.823.0" +version = "1.823.1" dependencies = [ "async-stream", "async-trait", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "argon2", @@ -15105,7 +15105,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15145,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15171,7 +15171,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.823.0" +version = "1.823.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15198,7 +15198,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15220,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15259,7 +15259,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15303,7 +15303,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-nats", @@ -15353,7 +15353,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15378,7 +15378,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15406,7 +15406,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15428,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15448,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15490,7 +15490,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.823.0" +version = "1.823.1" dependencies = [ "lazy_static", "serde", @@ -15531,7 +15531,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.823.0" +version = "1.823.1" dependencies = [ "argon2", "axum 0.8.9", @@ -15555,7 +15555,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.823.0" +version = "1.823.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15608,7 +15608,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.823.0" +version = "1.823.1" dependencies = [ "chrono", "lazy_static", @@ -15622,7 +15622,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15641,7 +15641,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.823.0" +version = "1.823.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -15755,7 +15755,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.823.0" +version = "1.823.1" dependencies = [ "chrono", "futures", @@ -15775,7 +15775,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.823.0" +version = "1.823.1" dependencies = [ "regex", "serde", @@ -15792,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15819,7 +15819,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "futures", @@ -15836,7 +15836,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.823.0" +version = "1.823.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15852,7 +15852,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -15873,7 +15873,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -15904,7 +15904,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "arc-swap", @@ -15929,7 +15929,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-stream", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "futures", @@ -15983,7 +15983,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.823.0" +version = "1.823.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -15992,7 +15992,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -16004,7 +16004,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde_json", @@ -16016,7 +16016,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "gosyn", @@ -16028,7 +16028,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -16040,7 +16040,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde_json", @@ -16052,7 +16052,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "nu-parser", @@ -16063,7 +16063,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16086,7 +16086,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-recursion", @@ -16119,7 +16119,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde_json", @@ -16131,7 +16131,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -16145,7 +16145,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16162,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -16175,7 +16175,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde", @@ -16187,7 +16187,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -16205,7 +16205,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16221,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16237,7 +16237,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -16251,7 +16251,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-recursion", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "const_format", @@ -16330,7 +16330,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.823.0" +version = "1.823.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16341,7 +16341,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-recursion", @@ -16376,7 +16376,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16400,7 +16400,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16433,7 +16433,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16460,7 +16460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16493,7 +16493,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16513,7 +16513,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16547,7 +16547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16584,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16607,7 +16607,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16631,7 +16631,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-nats", @@ -16655,7 +16655,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16718,7 +16718,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-trait", @@ -16743,7 +16743,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16762,7 +16762,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-once-cell", @@ -16881,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.823.0" +version = "1.823.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d420ef30df..e8eef06ba6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.823.0" +version = "1.823.1" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.823.0" +version = "1.823.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 1c506cb136..612e2eed89 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.823.0" +version = "1.823.1" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.823.0" +version = "1.823.1" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.823.0" +version = "1.823.1" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.823.0" +version = "1.823.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index c24a77b53d..672e4c9920 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.823.0" +version = "1.823.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 061cdb69a6..7e7636a3fd 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.823.0 + version: 1.823.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 5425ce14ba..246bc3a740 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.823.0"; +export const VERSION = "v1.823.1"; 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 8e842e24cb..d4fc8e642f 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.823.0", + "version": "1.823.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.823.0", + "version": "1.823.1", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index c0f991b07a..59fb868ef9 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.823.0", + "version": "1.823.1", "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 ac342792d7..5c88f85b14 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.823.0"; +export const VERSION = "1.823.1"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 772287b13b..7fa720990a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.823.0", + "version": "1.823.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.823.0", + "version": "1.823.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index e5b25e3db7..1c0eec4435 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.823.0", + "version": "1.823.1", "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 a3e3d64a99..5d6cf2b3f9 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.823.0" +wmill = ">=1.823.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index fc6ec4fb05..7a0ec0963e 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.823.0 + version: 1.823.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index d268971f2a..c4deec8281 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.823.0' + ModuleVersion = '1.823.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 99c18181e1..9826d7ad8c 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.823.0" +version = "1.823.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 74ce5a2491..00e9e015b7 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.823.0", + "version": "1.823.1", "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 43a13bc67b..79141f7dfc 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.823.0", + "version": "1.823.1", "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 3022a31085..0125cfae83 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.823.0 +1.823.1 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 20df8c8c28..f08e6270b0 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.823.0", + "version": "1.823.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.823.0", + "version": "1.823.1", "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 d647edfd15..1cc26b27f9 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.823.0", + "version": "1.823.1", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From af1a29a3cb33386f61a759ec0dd5eec0ccefefc2 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 5 Oct 2026 10:34:05 +0200 Subject: [PATCH 032/174] feat: card layout and load-more for the audit logs page (#11507) * feat: card layout and load-more for the audit logs page * fix: stream audit log load-more with stop control and show inline filters from fhd * fix: fit audit log filters to measured header width and hold timeline during load-more * fix: keep audit log load-more available when a trimmed short batch has rows left --- .../auditLogs/AuditLogDetails.svelte | 36 +- .../auditLogs/AuditLogsFilters.svelte | 12 +- .../auditLogs/AuditLogsTable.svelte | 384 +++++++++--------- .../auditLogs/AuditLogsTimeline.svelte | 2 +- .../auditLogs/useAuditLogsLoader.svelte.ts | 127 ++++-- .../auditLogs/useAuditLogsLoader.test.ts | 19 - .../(root)/(logged)/audit_logs/+page.svelte | 168 +++++--- 7 files changed, 417 insertions(+), 331 deletions(-) delete mode 100644 frontend/src/lib/components/auditLogs/useAuditLogsLoader.test.ts diff --git a/frontend/src/lib/components/auditLogs/AuditLogDetails.svelte b/frontend/src/lib/components/auditLogs/AuditLogDetails.svelte index 392018750d..508543b49b 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogDetails.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogDetails.svelte @@ -1,14 +1,16 @@ -
+
{#if selectedId} {@const log = logs.find((e) => e.id === selectedId)} {#if log} -
+
+
+ {log.operation} + {displayDate(log.timestamp)} +
+ {#if onClose} +
+
ID {log.id} @@ -49,9 +67,10 @@ {#if log?.parameters?.uuid}
{/if} {:else} - No log selected + No log selected {/if}
diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index 4090a39dbb..9817b667f7 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -44,7 +44,6 @@ interface Props { logs?: AuditLog[] username?: string - pageIndex?: number | undefined before?: string | undefined after?: string | undefined perPage?: number | undefined @@ -53,13 +52,14 @@ actionKind?: ActionKind | 'all' scope?: undefined | 'all_workspaces' | 'instance' loading?: boolean + /** Lay the filters out in a row (page header) rather than stacked (popover). */ + inline?: boolean onRefresh?: () => void } let { logs = undefined, username = $bindable('all'), - pageIndex = $bindable(1), before = $bindable(undefined), after = $bindable(undefined), perPage = $bindable(100), @@ -68,6 +68,7 @@ actionKind = $bindable(undefined), scope = $bindable(undefined), loading = false, + inline = false, onRefresh }: Props = $props() @@ -100,7 +101,6 @@ } addQueryParam('username', username) - addQueryParam('page', pageIndex) addQueryParam('perPage', perPage) addQueryParam('before', before) addQueryParam('after', after) @@ -120,7 +120,6 @@ function handlePopState() { const urlSearchParams = new URLSearchParams(window.location.search) username = urlSearchParams.get('username') ?? 'all' - pageIndex = Number(urlSearchParams.get('page')) || 1 before = urlSearchParams.get('before') ?? undefined after = urlSearchParams.get('after') ?? undefined perPage = Number(urlSearchParams.get('perPage')) || 100 @@ -263,12 +262,12 @@ // observe all the variables that should be reflected in the url $effect(() => { - ;[username, perPage, before, after, operation, resource, actionKind, scope, pageIndex] + ;[username, perPage, before, after, operation, resource, actionKind, scope] untrack(() => updateQueryParams()) }) -
+
{#if $workspaceStore == 'admins'}
Scope @@ -406,7 +405,6 @@ username = 'all' operation = 'all' actionKind = 'all' - pageIndex = 1 perPage = 100 resource = 'all' scope = undefined diff --git a/frontend/src/lib/components/auditLogs/AuditLogsTable.svelte b/frontend/src/lib/components/auditLogs/AuditLogsTable.svelte index 372b2aadd3..5c2c140632 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsTable.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsTable.svelte @@ -1,17 +1,16 @@ - computeHeight()} /> - -
-
-
-
ID
-
Timestamp
-
Username
- {#if showWorkspace} -
Workspace
- {/if} -
Operation
-
Resource
-
+
+ +
+
ID
+
Timestamp
+
Username
+ {#if showWorkspace} +
Workspace
+ {/if} +
Operation
+
Resource
- {#if loading} -
- -
- {:else if !logs?.length} -
- No logs found for the selected filters. -
- {:else} - { - if (flatLogs?.[index]?.type === 'date') { - return 33 - } - return 42 - }} - overscanCount={20} - {stickyIndices} - scrollToAlignment="center" - > - {#snippet header()}{/snippet} - {#snippet item({ index, style })} -
- {#if flatLogs} - {@const logOrDate = flatLogs[index]} +
+ {#if loading} +
+ +
+ {:else if !logs?.length} +
+ No logs found for the selected filters. +
+ {:else} + { + if (flatLogs?.[index]?.type === 'date') { + return 33 + } + return 42 + }} + overscanCount={20} + {stickyIndices} + scrollToAlignment="center" + > + {#snippet header()}{/snippet} + {#snippet item({ index, style })} +
+ {#if flatLogs} + {@const logOrDate = flatLogs[index]} - {#if logOrDate} - {#if logOrDate?.type === 'date'} -
- {logOrDate.date} -
- {:else} - - -
{ - onselect?.(logOrDate.log.id) - }} - > -
- {logOrDate.log.id} + {#if logOrDate} + {#if logOrDate?.type === 'date'} +
+ {logOrDate.date}
-
- {displayDate(logOrDate.log.timestamp)} -
-
-
- + +
{ + onselect?.(logOrDate.log.id) + }} + > +
+ {logOrDate.log.id} +
+
+ {displayDate(logOrDate.log.timestamp)} +
+
+
+ -
- - {logOrDate.log.username} - - {#if logOrDate.log.parameters && 'end_user' in logOrDate.log.parameters} - - ({logOrDate.log.parameters.end_user}) +
+ + {logOrDate.log.username} - {/if} + {#if logOrDate.log.parameters && 'end_user' in logOrDate.log.parameters} + + ({logOrDate.log.parameters.end_user}) + + {/if} +
+
+
+ {#if showWorkspace} +
+
+ {logOrDate.log.workspace_id} +
+
+ {/if} +
+
+ { + actionKind = logOrDate.log.action_kind.toLocaleLowerCase() + }} + color={kindToBadgeColor(logOrDate.log.action_kind)} + > + {logOrDate.log.action_kind} + + { + operation = logOrDate.log.operation + }} + > + {logOrDate.log.operation} +
-
-
- {#if showWorkspace}
-
- {logOrDate.log.workspace_id} +
+
+ {logOrDate.log.resource} +
+
- {/if} -
-
- { - actionKind = logOrDate.log.action_kind.toLocaleLowerCase() - }} - color={kindToBadgeColor(logOrDate.log.action_kind)} - > - {logOrDate.log.action_kind} - - { - operation = logOrDate.log.operation - }} - > - {logOrDate.log.operation} - -
-
-
-
-
- {logOrDate.log.resource} -
-
+ {/if} + {:else} +
+
Loading...
{/if} {:else} @@ -291,46 +288,29 @@
Loading...
{/if} - {:else} -
-
Loading...
+
+ {/snippet} + {#snippet footer()} + {#if hasMore && !batchProgress} +
+
{/if} -
- {/snippet} - {#snippet footer()}{/snippet} - - {/if} - + {/snippet} + + {/if} +
-
- - Page {pageIndex} - -
+ {pluralize(logs?.length ?? 0, 'log')} {#if batchProgress}
{/if} -
- Per page: - -
+ Step inputs
+ {:else if stepDetail.value.type == 'aidecision'} +
+

Step inputs

+ +
{:else if stepDetail.value.type == 'forloopflow'}

Iterator expression:

diff --git a/frontend/src/lib/components/FlowGraphViewerStepHeader.svelte b/frontend/src/lib/components/FlowGraphViewerStepHeader.svelte index 9fda9f23e5..8b40b1a4a9 100644 --- a/frontend/src/lib/components/FlowGraphViewerStepHeader.svelte +++ b/frontend/src/lib/components/FlowGraphViewerStepHeader.svelte @@ -54,6 +54,8 @@ return 'Workspace script' case 'aiagent': return 'AI Agent' + case 'aidecision': + return 'AI decision' default: return stepDetail.id } diff --git a/frontend/src/lib/components/JsonEditor.svelte b/frontend/src/lib/components/JsonEditor.svelte index 8a42200ea8..3d23d16623 100644 --- a/frontend/src/lib/components/JsonEditor.svelte +++ b/frontend/src/lib/components/JsonEditor.svelte @@ -19,6 +19,7 @@ class?: string | undefined disabled?: boolean fixedOverflowWidgets?: boolean + placeholder?: string } let { @@ -30,7 +31,8 @@ loadAsync = false, class: clazz = undefined, disabled = false, - fixedOverflowWidgets = true + fixedOverflowWidgets = true, + placeholder = undefined }: Props = $props() let tooBig = $derived(code && code?.length > 1000000) @@ -101,6 +103,7 @@ class={clazz} {disabled} {fixedOverflowWidgets} + {placeholder} renderLineHighlight="none" />
diff --git a/frontend/src/lib/components/ModulePreview.svelte b/frontend/src/lib/components/ModulePreview.svelte index be62557b81..ac13a8fced 100644 --- a/frontend/src/lib/components/ModulePreview.svelte +++ b/frontend/src/lib/components/ModulePreview.svelte @@ -1,4 +1,5 @@ + +{#if ctx && question} + {@const q = question} +
+
+{/if} diff --git a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte index 4ba08a57a8..53679a0de1 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/ModuleNode.svelte @@ -2,6 +2,7 @@ import MapItem from '$lib/components/flows/map/MapItem.svelte' import { GitBranchPlus, Move, Copy, Trash2, StickyNote, PictureInPicture2 } from 'lucide-svelte' import NodeWrapper from './NodeWrapper.svelte' + import AiDecisionNodeBranch from './AiDecisionNodeBranch.svelte' import type { ModuleN } from '../../graphBuilder.svelte' import { jobToGraphModuleState } from '$lib/components/modulesTest.svelte' import { getNoteEditorContext } from '../../noteEditor.svelte' @@ -237,5 +238,8 @@
{/if} + {#if data.module?.value?.type === 'aidecision' && data.insertable} + + {/if} {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/nodes/NewAIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/NewAIToolNode.svelte index d67a0a74be..7c088c119a 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NewAIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NewAIToolNode.svelte @@ -114,6 +114,14 @@ }) close() }} + on:pickAiDecisionTool={(e) => { + data.eventHandlers.insert({ + index: -1, + agentId: data.agentModuleId, + kind: 'aiDecisionTool' + }) + close() + }} /> {/snippet} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b771b4966e..ec112607fd 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -629,6 +629,7 @@ export type InputCat = | 'dynamic' | 'json-schema' | 'ai-provider' + | 'ai-decision-questions' export namespace DynamicInput { const DYN_FORMAT_PREFIX = ['dynmultiselect-', 'dynselect-'] @@ -707,8 +708,10 @@ export function setInputCat( return 'list' } else if (type == 'object' && format?.startsWith('resource')) { return 'resource-object' - } else if (type == 'object' && format == 'ai-provider') { + } else if (type == 'object' && (format == 'ai-provider' || format == 'ai-decision-provider')) { return 'ai-provider' + } else if (type == 'object' && format == 'ai-decision-questions') { + return 'ai-decision-questions' } else if (type == 'object' && DynamicInput.isDynInputFormat(format)) { return 'dynamic' } else if (!type || type == 'object' || type == 'array') { diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 7a0ec0963e..9ba817f772 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -479,6 +479,8 @@ components: - togetherai - customai - aws_bedrock + - typesafe + - cloudflare ProviderConfig: type: object @@ -685,6 +687,7 @@ components: - $ref: '#/components/schemas/BranchAll' - $ref: '#/components/schemas/Identity' - $ref: '#/components/schemas/AiAgent' + - $ref: '#/components/schemas/AiDecision' discriminator: propertyName: type mapping: @@ -697,6 +700,7 @@ components: branchall: '#/components/schemas/BranchAll' identity: '#/components/schemas/Identity' aiagent: '#/components/schemas/AiAgent' + aidecision: '#/components/schemas/AiDecision' RawScript: type: object @@ -1224,6 +1228,62 @@ components: - type - input_transforms + AiDecision: + type: object + description: | + AI decision step: one call to a decision model (TypeSafe's Jev, or Cloudflare's + Jev-compatible Clef) that answers typed questions about a state with calibrated + probabilities, instead of generating text. + Prefer it over an AI agent to classify, route, score or check something: it is fast, + cheap and its answers are structured. Its result is { output: { : answer }, + model, usage }, so a later step reads e.g. results..output.intent.choice. To route on + the answers, follow it with a branchone whose branch exprs read + results..output..choice. Usable as an AI agent tool, where the calling agent + supplies the state. + properties: + input_transforms: + type: object + description: Input parameters for the decision mapped to their values + properties: + # A provider config whose kind is 'typesafe' (model 'jev-latest' unless pinned) or + # 'cloudflare' (model 'clef' or 'clef-flash'). + provider: + $ref: '#/components/schemas/ProviderTransform' + state: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + What the questions are asked about: a string, an object or an array of strings. + An object with descriptive keys holding only what the questions need works best. + Example: { message: flow_input.message, plan: results.a.plan } + questions: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + Object mapping each question name to { type, instructions, criteria }, where type is: + - 'choice': criteria maps each option to its description (up to 255); the answer + has choice, probabilities and confidence + - 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer + has score, legend, probabilities and confidence + - 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has + noul, the probability of yes (0 to 1) + Example: { intent: { type: 'choice', instructions: 'What does the customer want?', + criteria: { refund: 'Money back', bug: 'Something is broken' } } } + required: + - provider + - state + - questions + type: + type: string + enum: + - aidecision + tag: + type: string + description: Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`) + required: + - type + - input_transforms + Identity: type: object description: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index a575708f18..6ecb856a4c 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -177,6 +177,91 @@ names, so neither is name-checked at all — leave those summaries as they are. whenever the name alone does not make that obvious; it overrides the description derived from the underlying script +## AI Decision Modules + +An `aidecision` module asks a decision model (TypeSafe's Jev or Cloudflare's Clef) typed questions about a `state` and +answers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step +is a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it +is faster, cheaper, and its answers have a fixed shape. + +```json +{ + "id": "triage", + "summary": "Classify the ticket", + "value": { + "type": "aidecision", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "typesafe", "resource": "$res:f/ai/typesafe", "model": "jev-latest" } + }, + "state": { "type": "javascript", "expr": "flow_input.message" }, + "questions": { + "type": "static", + "value": { + "intent": { + "type": "choice", + "instructions": "What does the customer want?", + "criteria": { "refund": "Money back", "bug": "Something is broken", "other": "Anything else" } + }, + "urgency": { + "type": "score", + "instructions": "How urgent is the ticket?", + "criteria": ["Can wait", "This week", "Today", "Right now"] + }, + "angry": { "type": "noul", "instructions": "Is the customer angry?" } + } + } + } + } +} +``` + +- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned) or `cloudflare` + (`model` `clef`, or `clef-flash` for faster answers); the resource is of that same type +- `state` is the content to evaluate: usually a text, such as the message to classify. To combine + several values, pass an object with descriptive keys holding only what the questions need + (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works +- `questions` maps each question name to `{ type, instructions, criteria }`: + - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has + `choice`, `probabilities` and `confidence` + - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`, + `legend`, `probabilities` and `confidence` + - `noul`: yes/no, `criteria` optionally `{ "true": ..., "false": ... }` descriptions. The answer has + `noul`, the probability of yes from 0 to 1 +- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step + reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or + `results.triage.output.angry.noul > 0.7` + +### Branching on the Answers + +To route on the answers, put a `branchone` right after the decision, one branch per option, with +each `expr` reading the decision by id: + +```json +{ + "id": "route", + "value": { + "type": "branchone", + "branches": [ + { "summary": "refund", "expr": "results.triage.output.intent.choice === 'refund'", "modules": [...] }, + { "summary": "bug", "expr": "results.triage.output.intent.choice === 'bug'", "modules": [...] } + ], + "default": [...] + } +} +``` + +For a yes/no question, one branch with `"expr": "results.triage.output.angry.noul >= 0.5"` (tune the +threshold as needed) and the `default` as the "no" path. + +### As an Agent Tool + +An `aidecision` can be a `flowmodule` tool of an `aiagent` (`"tool_type": "flowmodule", "type": +"aidecision"`): set `state` to `{ "type": "ai" }` so the calling model supplies it, and only `output` +goes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step +can call one, not an agent used as a tool. + ## Common Mistakes to Avoid - Missing `input_transforms` - Rawscript parameters won't receive values without them @@ -501,4 +586,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, or Cloudflare's\nJev-compatible Clef) that answers typed questions about a state with calibrated\nprobabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { : answer },\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults..output..choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 27dc888291..211b73f786 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -204,6 +204,91 @@ names, so neither is name-checked at all — leave those summaries as they are. whenever the name alone does not make that obvious; it overrides the description derived from the underlying script +## AI Decision Modules + +An \`aidecision\` module asks a decision model (TypeSafe's Jev or Cloudflare's Clef) typed questions about a \`state\` and +answers each with calibrated probabilities instead of text. Prefer it over an \`aiagent\` when the step +is a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it +is faster, cheaper, and its answers have a fixed shape. + +\`\`\`json +{ + "id": "triage", + "summary": "Classify the ticket", + "value": { + "type": "aidecision", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "typesafe", "resource": "$res:f/ai/typesafe", "model": "jev-latest" } + }, + "state": { "type": "javascript", "expr": "flow_input.message" }, + "questions": { + "type": "static", + "value": { + "intent": { + "type": "choice", + "instructions": "What does the customer want?", + "criteria": { "refund": "Money back", "bug": "Something is broken", "other": "Anything else" } + }, + "urgency": { + "type": "score", + "instructions": "How urgent is the ticket?", + "criteria": ["Can wait", "This week", "Today", "Right now"] + }, + "angry": { "type": "noul", "instructions": "Is the customer angry?" } + } + } + } + } +} +\`\`\` + +- \`provider.kind\` is \`typesafe\` (\`model\` \`jev-latest\` unless a version is pinned) or \`cloudflare\` + (\`model\` \`clef\`, or \`clef-flash\` for faster answers); the resource is of that same type +- \`state\` is the content to evaluate: usually a text, such as the message to classify. To combine + several values, pass an object with descriptive keys holding only what the questions need + (\`({ message: flow_input.message, plan: results.get_account.plan })\`); an array of strings also works +- \`questions\` maps each question name to \`{ type, instructions, criteria }\`: + - \`choice\`: \`criteria\` maps each option to its description (up to 255 options). The answer has + \`choice\`, \`probabilities\` and \`confidence\` + - \`score\`: \`criteria\` is an ordered array of 2 to 10 level descriptions. The answer has \`score\`, + \`legend\`, \`probabilities\` and \`confidence\` + - \`noul\`: yes/no, \`criteria\` optionally \`{ "true": ..., "false": ... }\` descriptions. The answer has + \`noul\`, the probability of yes from 0 to 1 +- The result is \`{ output, model, usage }\` with \`output\` keyed by question name, so a later step + reads \`results.triage.output.intent.choice\`, \`results.triage.output.urgency.score\` or + \`results.triage.output.angry.noul > 0.7\` + +### Branching on the Answers + +To route on the answers, put a \`branchone\` right after the decision, one branch per option, with +each \`expr\` reading the decision by id: + +\`\`\`json +{ + "id": "route", + "value": { + "type": "branchone", + "branches": [ + { "summary": "refund", "expr": "results.triage.output.intent.choice === 'refund'", "modules": [...] }, + { "summary": "bug", "expr": "results.triage.output.intent.choice === 'bug'", "modules": [...] } + ], + "default": [...] + } +} +\`\`\` + +For a yes/no question, one branch with \`"expr": "results.triage.output.angry.noul >= 0.5"\` (tune the +threshold as needed) and the \`default\` as the "no" path. + +### As an Agent Tool + +An \`aidecision\` can be a \`flowmodule\` tool of an \`aiagent\` (\`"tool_type": "flowmodule", "type": +"aidecision"\`): set \`state\` to \`{ "type": "ai" }\` so the calling model supplies it, and only \`output\` +goes back to the model. The Tool Naming Rules apply to its \`summary\`. Only a flow's own agent step +can call one, not an agent used as a tool. + ## Common Mistakes to Avoid - Missing \`input_transforms\` - Rawscript parameters won't receive values without them @@ -3347,7 +3432,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, or Cloudflare's\\nJev-compatible Clef) that answers typed questions about a state with calibrated\\nprobabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { : answer },\\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults..output..choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default \`flow\`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index a6a654c863..66bf3d0fa4 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -264,6 +264,91 @@ names, so neither is name-checked at all — leave those summaries as they are. whenever the name alone does not make that obvious; it overrides the description derived from the underlying script +## AI Decision Modules + +An `aidecision` module asks a decision model (TypeSafe's Jev or Cloudflare's Clef) typed questions about a `state` and +answers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step +is a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it +is faster, cheaper, and its answers have a fixed shape. + +```json +{ + "id": "triage", + "summary": "Classify the ticket", + "value": { + "type": "aidecision", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "typesafe", "resource": "$res:f/ai/typesafe", "model": "jev-latest" } + }, + "state": { "type": "javascript", "expr": "flow_input.message" }, + "questions": { + "type": "static", + "value": { + "intent": { + "type": "choice", + "instructions": "What does the customer want?", + "criteria": { "refund": "Money back", "bug": "Something is broken", "other": "Anything else" } + }, + "urgency": { + "type": "score", + "instructions": "How urgent is the ticket?", + "criteria": ["Can wait", "This week", "Today", "Right now"] + }, + "angry": { "type": "noul", "instructions": "Is the customer angry?" } + } + } + } + } +} +``` + +- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned) or `cloudflare` + (`model` `clef`, or `clef-flash` for faster answers); the resource is of that same type +- `state` is the content to evaluate: usually a text, such as the message to classify. To combine + several values, pass an object with descriptive keys holding only what the questions need + (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works +- `questions` maps each question name to `{ type, instructions, criteria }`: + - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has + `choice`, `probabilities` and `confidence` + - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`, + `legend`, `probabilities` and `confidence` + - `noul`: yes/no, `criteria` optionally `{ "true": ..., "false": ... }` descriptions. The answer has + `noul`, the probability of yes from 0 to 1 +- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step + reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or + `results.triage.output.angry.noul > 0.7` + +### Branching on the Answers + +To route on the answers, put a `branchone` right after the decision, one branch per option, with +each `expr` reading the decision by id: + +```json +{ + "id": "route", + "value": { + "type": "branchone", + "branches": [ + { "summary": "refund", "expr": "results.triage.output.intent.choice === 'refund'", "modules": [...] }, + { "summary": "bug", "expr": "results.triage.output.intent.choice === 'bug'", "modules": [...] } + ], + "default": [...] + } +} +``` + +For a yes/no question, one branch with `"expr": "results.triage.output.angry.noul >= 0.5"` (tune the +threshold as needed) and the `default` as the "no" path. + +### As an Agent Tool + +An `aidecision` can be a `flowmodule` tool of an `aiagent` (`"tool_type": "flowmodule", "type": +"aidecision"`): set `state` to `{ "type": "ai" }` so the calling model supplies it, and only `output` +goes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step +can call one, not an agent used as a tool. + ## Common Mistakes to Avoid - Missing `input_transforms` - Rawscript parameters won't receive values without them @@ -589,4 +674,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools)."},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\n`memory_id`). Without a memory id the agent runs without memory.\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\nits older part with a summary as the conversation approaches the model's context window.\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\nWindmill does not know needs one; those fall back to 128000.\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"},{"$ref":"#/components/schemas/AiDecision"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent","aidecision":"#/components/schemas/AiDecision"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"job_token_scopes":{"type":"array","nullable":true,"items":{"type":"string"},"description":"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\nthe prompt; image output always needs it.\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\nwas started with (the chat conversation, an app chat session or the `memory_id` run\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\nkey. When it evaluates to an empty value the agent runs without memory. Read only\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\nolder `auto` or `manual` memory reads neither history input.\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\nand the user message. Read only while `memory` is off or absent: managed memory\nignores it, and an older `auto` or `manual` memory reads neither history input.\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in `tools` the agent may call\nthis run. Leaving it unset carries every one of them; an empty array carries none.\nA tool is named as the model is shown it. An entry the model is shown nothing of is\nnamed by what identifies it instead: an MCP server by its resource path, carrying\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\n(no tool may take that name).\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\nthat resource; the module's input_transforms then only carry the flow-local inputs\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\nshared resource; overlaid onto the tools' input_transforms at runtime \u2014 including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"AiDecision":{"type":"object","description":"AI decision step: one call to a decision model (TypeSafe's Jev, or Cloudflare's\nJev-compatible Clef) that answers typed questions about a state with calibrated\nprobabilities, instead of generating text.\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\ncheap and its answers are structured. Its result is { output: { : answer },\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults..output..choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n","properties":{"input_transforms":{"type":"object","description":"Input parameters for the decision mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"state":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"What the questions are asked about: a string, an object or an array of strings.\nAn object with descriptive keys holding only what the questions need works best.\nExample: { message: flow_input.message, plan: results.a.plan }\n"},"questions":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Object mapping each question name to { type, instructions, criteria }, where type is:\n- 'choice': criteria maps each option to its description (up to 255); the answer\n has choice, probabilities and confidence\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\n has score, legend, probabilities and confidence\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\n noul, the probability of yes (0 to 1)\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\n"}},"required":["provider","state","questions"]},"type":{"type":"string","enum":["aidecision"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index 5c5c93411d..c615a628f3 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -182,6 +182,91 @@ names, so neither is name-checked at all — leave those summaries as they are. whenever the name alone does not make that obvious; it overrides the description derived from the underlying script +## AI Decision Modules + +An `aidecision` module asks a decision model (TypeSafe's Jev or Cloudflare's Clef) typed questions about a `state` and +answers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step +is a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it +is faster, cheaper, and its answers have a fixed shape. + +```json +{ + "id": "triage", + "summary": "Classify the ticket", + "value": { + "type": "aidecision", + "input_transforms": { + "provider": { + "type": "static", + "value": { "kind": "typesafe", "resource": "$res:f/ai/typesafe", "model": "jev-latest" } + }, + "state": { "type": "javascript", "expr": "flow_input.message" }, + "questions": { + "type": "static", + "value": { + "intent": { + "type": "choice", + "instructions": "What does the customer want?", + "criteria": { "refund": "Money back", "bug": "Something is broken", "other": "Anything else" } + }, + "urgency": { + "type": "score", + "instructions": "How urgent is the ticket?", + "criteria": ["Can wait", "This week", "Today", "Right now"] + }, + "angry": { "type": "noul", "instructions": "Is the customer angry?" } + } + } + } + } +} +``` + +- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned) or `cloudflare` + (`model` `clef`, or `clef-flash` for faster answers); the resource is of that same type +- `state` is the content to evaluate: usually a text, such as the message to classify. To combine + several values, pass an object with descriptive keys holding only what the questions need + (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works +- `questions` maps each question name to `{ type, instructions, criteria }`: + - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has + `choice`, `probabilities` and `confidence` + - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`, + `legend`, `probabilities` and `confidence` + - `noul`: yes/no, `criteria` optionally `{ "true": ..., "false": ... }` descriptions. The answer has + `noul`, the probability of yes from 0 to 1 +- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step + reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or + `results.triage.output.angry.noul > 0.7` + +### Branching on the Answers + +To route on the answers, put a `branchone` right after the decision, one branch per option, with +each `expr` reading the decision by id: + +```json +{ + "id": "route", + "value": { + "type": "branchone", + "branches": [ + { "summary": "refund", "expr": "results.triage.output.intent.choice === 'refund'", "modules": [...] }, + { "summary": "bug", "expr": "results.triage.output.intent.choice === 'bug'", "modules": [...] } + ], + "default": [...] + } +} +``` + +For a yes/no question, one branch with `"expr": "results.triage.output.angry.noul >= 0.5"` (tune the +threshold as needed) and the `default` as the "no" path. + +### As an Agent Tool + +An `aidecision` can be a `flowmodule` tool of an `aiagent` (`"tool_type": "flowmodule", "type": +"aidecision"`): set `state` to `{ "type": "ai" }` so the calling model supplies it, and only `output` +goes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step +can call one, not an agent used as a tool. + ## Common Mistakes to Avoid - Missing `input_transforms` - Rawscript parameters won't receive values without them From 222dad1d8cc9cbb6925fdff709e00a49bb63bc4a Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 5 Oct 2026 16:43:41 +0200 Subject: [PATCH 043/174] fix: run each ai eval model in its own job and let the docs tools reach the backend (#11533) Co-authored-by: Claude Opus 5.5 --- .github/workflows/ai-evals-scheduled.yml | 31 ++++++++++--------- .../adapters/frontend/vitestAdapter.test.ts | 12 +++++-- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index cc1c0d6763..36ef8e00a7 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -4,8 +4,9 @@ name: AI Evals (scheduled) # the windmill-prod workspace (f/ai/ai_evals_dashboard) so quality is tracked # over time. Weekly, since one pass of every suite at --runs 3 costs about 50M # tokens per model: every suite runs on MODELS, and global (the mode users get) -# also runs on GLOBAL_EXTRA_MODELS, one flagship per other provider. Run it by -# hand to measure a branch against main. +# also runs on GLOBAL_EXTRA_MODELS, one flagship per other provider. Each mode and +# model is its own job, so a slow model neither delays nor shares a timeout with +# the others. Run it by hand to measure a branch against main. on: schedule: - cron: "0 3 * * 1" @@ -42,14 +43,17 @@ jobs: setup: runs-on: ubuntu-latest outputs: - modes: ${{ steps.modes.outputs.modes }} + matrix: ${{ steps.matrix.outputs.matrix }} steps: - - id: modes + - id: matrix env: MODES: ${{ inputs.modes || 'global flow app script cli' }} run: | - echo "modes=$(jq -cn --arg m "$MODES" '$m | split(" ") - | map(select(IN("global", "flow", "app", "script", "cli")))')" >> "$GITHUB_OUTPUT" + echo "matrix=$(jq -cn --arg modes "$MODES" --arg models "$MODELS" --arg extra "$GLOBAL_EXTRA_MODELS" ' + def words: split(" ") | map(select(. != "")); + [ $modes | words[] | select(IN("global", "flow", "app", "script", "cli")) as $mode + | ($models | words) + (if $mode == "global" then $extra | words else [] end) + | .[] | {mode: $mode, model: .} ]')" >> "$GITHUB_OUTPUT" evals: needs: setup @@ -58,7 +62,7 @@ jobs: strategy: fail-fast: false matrix: - mode: ${{ fromJSON(needs.setup.outputs.modes) }} + include: ${{ fromJSON(needs.setup.outputs.matrix) }} services: postgres: image: postgres:16 @@ -118,10 +122,11 @@ jobs: working-directory: ./cli run: bun install && ./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh - - name: Run ${{ matrix.mode }} evals + - name: Run ${{ matrix.mode }} evals on ${{ matrix.model }} working-directory: ./ai_evals env: MODE: ${{ matrix.mode }} + MODEL: ${{ matrix.model }} WMILL_AI_EVAL_BACKEND_URL: http://localhost:8000 WMILL_AI_EVAL_BACKEND_WORKSPACE: integration-tests ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -131,14 +136,10 @@ jobs: run: | bun install mkdir -p results - models="$MODELS" - [ "$MODE" = global ] && models="$models $GLOBAL_EXTRA_MODELS" reasoning=() [ -n "$REASONING" ] && [ "$MODE" != cli ] && reasoning=(--reasoning "$REASONING") - for m in $models; do - bun run cli -- run "$MODE" --model "$m" --runs "$RUNS" "${reasoning[@]}" \ - --output "$PWD/results/$MODE-$m.json" || echo "::warning::$MODE on $m errored" - done + bun run cli -- run "$MODE" --model "$MODEL" --runs "$RUNS" "${reasoning[@]}" \ + --output "$PWD/results/$MODE-$MODEL.json" || echo "::warning::$MODE on $MODEL errored" - name: Post results to the dashboard if: always() @@ -166,7 +167,7 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: ai-evals-${{ matrix.mode }} + name: ai-evals-${{ matrix.mode }}-${{ matrix.model }} path: | ai_evals/logs ai_evals/results diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 9adb4d05ac..288c67e3eb 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -6,6 +6,8 @@ import { dirname, resolve } from 'path' // @ts-ignore - Node.js url import { fileURLToPath } from 'url' import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend' +import { resolveWindmillBackendSettings } from '../../core/windmillBackendSettings' +import { WindmillBackendClient } from './windmillBackend' const FRONTEND_DIR = fileURLToPath(new URL('../../../frontend/', import.meta.url)) @@ -28,6 +30,11 @@ globalThis.fetch = (async (input: unknown, init?: RequestInit) => { if (typeof url === 'string' && hasBenchmarkApiHandler(url)) { return handleBenchmarkApiFetch(url, init) } + // The docs tools read the documentation bundled into the backend: hand them to the + // benchmark's real backend, logged in like the AI proxy calls. + if (typeof url === 'string' && url.startsWith('/api/docs/')) { + return new WindmillBackendClient(resolveWindmillBackendSettings()).request(url.slice('/api'.length), init) + } // The parsers behind inferArgs load their wasm from a vite `?url` path, which only a dev // server serves. Unserved, every script schema infers as empty, and the tools then tell the // model its arguments are undeclared, so it rewrites a correct script until it runs out of turns. @@ -684,6 +691,7 @@ benchmarkIt( resetBenchmarkMockBackend() } }, - // Full-suite runs (30+ cases at concurrency 2-3) routinely exceed 10 minutes. - 7_200_000 + // A full suite on a slow reasoning model runs for hours (gpt-6-astra on global went past + // 2h); the CI job's own timeout is the real bound. + 18_000_000 ) From 4acb5b8f18f8b5f4a65ad71b40750f345f5fac0e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 5 Oct 2026 16:47:35 +0200 Subject: [PATCH 044/174] chore: bump go to 1.27.1 in the worker and extra images (#11537) * chore: bump go to 1.27.1 in the worker and extra images * chore: move the lsp scratch go.mod to go 1.27 --- Dockerfile | 2 +- backend/windmill-common/src/workspace_dependencies.rs | 2 +- docker/DockerfileExtra | 6 +++--- docker/entrypoint-extra.sh | 2 +- .../src/lib/components/WorkspaceDependenciesEditor.svelte | 2 +- lsp/pyls_launcher.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8d229dba56..f4b2da66c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -145,7 +145,7 @@ ARG POWERSHELL_VERSION=7.5.0 ARG KUBECTL_VERSION=1.36.2 ARG HELM_VERSION=3.21.4 # NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte -ARG GO_VERSION=1.26.8 +ARG GO_VERSION=1.27.1 ARG APP=/usr/src/app ARG WITH_POWERSHELL=true ARG WITH_KUBECTL=true diff --git a/backend/windmill-common/src/workspace_dependencies.rs b/backend/windmill-common/src/workspace_dependencies.rs index 5ce5ca7f68..bc964c4286 100644 --- a/backend/windmill-common/src/workspace_dependencies.rs +++ b/backend/windmill-common/src/workspace_dependencies.rs @@ -510,7 +510,7 @@ impl WorkspaceDependenciesPrefetched { // external.get(0).map(|wd| dbg!(wd.content.clone())).or(Some( // " // module mymod - // go 1.26 + // go 1.27 // require () // " // .to_owned(), diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index 8aa24206a4..ace382528b 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -44,9 +44,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN set -eux; \ arch="$(dpkg --print-architecture)"; arch="${arch##*-}"; \ case "$arch" in \ - 'amd64') targz='go1.26.0.linux-amd64.tar.gz' ;; \ - 'arm64') targz='go1.26.0.linux-arm64.tar.gz' ;; \ - 'armhf') targz='go1.26.0.linux-armv6l.tar.gz' ;; \ + 'amd64') targz='go1.27.1.linux-amd64.tar.gz' ;; \ + 'arm64') targz='go1.27.1.linux-arm64.tar.gz' ;; \ + 'armhf') targz='go1.27.1.linux-armv6l.tar.gz' ;; \ *) echo >&2 "error: unsupported architecture '$arch'"; exit 1 ;; \ esac; \ wget "https://golang.org/dl/$targz" -nv && tar -C /usr/local -xzf "$targz" && rm "$targz" diff --git a/docker/entrypoint-extra.sh b/docker/entrypoint-extra.sh index 84b337a8a1..c7d04c33e0 100644 --- a/docker/entrypoint-extra.sh +++ b/docker/entrypoint-extra.sh @@ -134,7 +134,7 @@ fi # Setup Monaco temp directory for LSP mkdir -p /tmp/monaco if [ ! -f /tmp/monaco/go.mod ]; then - echo -e "module mymod\ngo 1.26" > /tmp/monaco/go.mod + echo -e "module mymod\ngo 1.27" > /tmp/monaco/go.mod fi echo "[entrypoint] Starting Windmill Extra Services" diff --git a/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte b/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte index 7bc0351c82..2d5d8a62ce 100644 --- a/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte +++ b/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte @@ -154,7 +154,7 @@ numpy>=1.24.0 // go: `module mymod - // go 1.26 + // go 1.27 // require ( // rsc.io/quote v1.5.2 diff --git a/lsp/pyls_launcher.py b/lsp/pyls_launcher.py index 9feec0033a..9e522d8ce5 100644 --- a/lsp/pyls_launcher.py +++ b/lsp/pyls_launcher.py @@ -194,7 +194,7 @@ if __name__ == "__main__": go_mod_path = os.path.join(monaco_path, "go.mod") if not os.path.exists(go_mod_path): f = open(go_mod_path, "w") - f.write("module mymod\ngo 1.26") + f.write("module mymod\ngo 1.27") f.close() # Sync instance-level ruff config into /tmp/monaco/ruff.toml so every From 92fbfc03d219544c16e8f7db47a547f9ff402c27 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 5 Oct 2026 16:50:03 +0200 Subject: [PATCH 045/174] fix: bound s3 ai attachment downloads before encoding (#11534) --- backend/windmill-ai/src/image_handler.rs | 10 +- backend/windmill-common/src/client.rs | 108 +++++++++++++++++++- backend/windmill-worker/src/sql_s3_input.rs | 2 +- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/backend/windmill-ai/src/image_handler.rs b/backend/windmill-ai/src/image_handler.rs index 5bd9794da5..a4f360cdb4 100644 --- a/backend/windmill-ai/src/image_handler.rs +++ b/backend/windmill-ai/src/image_handler.rs @@ -47,6 +47,9 @@ pub async fn upload_image_to_s3( }) } +// Bounds each attachment's download before base64 expansion; provider limits may be lower. +const MAX_S3_MEDIA_BYTES: usize = 30 * 1024 * 1024; + /// Download an S3 image and convert it to a base64 data URL. /// /// The caller must provide an AuthedClient authorized for `workspace_id`. @@ -57,7 +60,12 @@ pub async fn download_and_encode_s3_image( ) -> Result<(String, String), Error> { // Download the image from S3 let image_bytes = client - .download_s3_file(workspace_id, &image.s3, image.storage.clone()) + .download_s3_file( + workspace_id, + &image.s3, + image.storage.clone(), + Some(MAX_S3_MEDIA_BYTES), + ) .await .map_err(|e| Error::internal_err(format!("Failed to download S3 image: {}", e)))?; diff --git a/backend/windmill-common/src/client.rs b/backend/windmill-common/src/client.rs index 48f3e0205b..985d3bbbfc 100644 --- a/backend/windmill-common/src/client.rs +++ b/backend/windmill-common/src/client.rs @@ -307,12 +307,13 @@ impl AuthedClient { workspace_id: &str, file_key: &str, storage: Option, + max_bytes: Option, ) -> anyhow::Result { let mut query = vec![("file_key", file_key.to_string())]; if let Some(storage) = storage { query.push(("storage", storage)); } - let response = self + let mut response = self .force_client .as_ref() .unwrap_or(&HTTP_CLIENT) @@ -331,6 +332,39 @@ impl AuthedClient { .context("Failed to send download_s3_file request") .map_err(|e| anyhow::anyhow!(e.to_string()))?; + if let Some(max_bytes) = max_bytes { + let size_error = || { + anyhow::anyhow!( + "S3 file '{file_key}' exceeds maximum supported download size of {max_bytes} bytes" + ) + }; + if response + .content_length() + .is_some_and(|len| len > max_bytes as u64) + { + return Err(size_error()); + } + + let status = response.status(); + let mut bytes = Vec::new(); + // Content-Length may be absent (including for decoded compressed responses). + while let Some(chunk) = response + .chunk() + .await + .context("Failed to read response bytes")? + { + if chunk.len() > max_bytes - bytes.len() { + return Err(size_error()); + } + bytes.extend_from_slice(&chunk); + } + return if status == reqwest::StatusCode::OK { + Ok(bytes.into()) + } else { + Err(anyhow::anyhow!(String::from_utf8_lossy(&bytes).into_owned())) + }; + } + match response.status().as_u16() { 200u16 => Ok(response .bytes() @@ -341,6 +375,78 @@ impl AuthedClient { } } +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + async fn download_response( + response: &'static str, + max_bytes: Option, + ) -> anyhow::Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let client = AuthedClient::new( + format!("http://{}", listener.local_addr().unwrap()), + "test_ws".to_string(), + "test_token".to_string(), + Some(reqwest::Client::builder().no_proxy().build().unwrap()), + ); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = BufReader::new(socket); + loop { + let mut line = String::new(); + assert!(socket.read_line(&mut line).await.unwrap() > 0); + if line == "\r\n" { + break; + } + } + socket + .get_mut() + .write_all(response.as_bytes()) + .await + .unwrap(); + // Keep incomplete bodies open so rejection must happen before EOF. + std::future::pending::<()>().await; + }); + let result = tokio::time::timeout( + Duration::from_secs(5), + client.download_s3_file("test_ws", "image.png", None, max_bytes), + ) + .await; + server.abort(); + result.expect("download waited for the oversized body to finish") + } + + #[tokio::test] + async fn download_s3_file_rejects_oversized_body_before_eof() { + for response in [ + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nabcd\r\n1\r\ne\r\n", + "HTTP/1.1 500 Internal Server Error\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nerror\r\n", + ] { + let error = download_response(response, Some(4)).await.unwrap_err(); + assert!(error.to_string().contains( + "S3 file 'image.png' exceeds maximum supported download size of 4 bytes" + )); + } + } + + #[tokio::test] + async fn download_s3_file_accepts_body_at_limit() { + for response in [ + "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nabcd", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nabcd\r\n0\r\n\r\n", + ] { + assert_eq!( + download_response(response, Some(4)).await.unwrap(), + &b"abcd"[..] + ); + } + } +} + #[inline] fn query_from_json_path(json_path: Option) -> Vec<(&'static str, String)> { json_path diff --git a/backend/windmill-worker/src/sql_s3_input.rs b/backend/windmill-worker/src/sql_s3_input.rs index e3a5b25411..ecf2c53dec 100644 --- a/backend/windmill-worker/src/sql_s3_input.rs +++ b/backend/windmill-worker/src/sql_s3_input.rs @@ -56,7 +56,7 @@ pub async fn fetch_s3object_as_json_text( crate::worker_utils::JobPingHeartbeat::start(conn, job_id, "s3object materialization"); let bytes = client - .download_s3_file(workspace_id, &obj.s3, obj.storage.clone()) + .download_s3_file(workspace_id, &obj.s3, obj.storage.clone(), None) .await .with_context(|| format!("Failed to download S3 object `{}`", obj.s3))?; From 27180726a3b43a14ee5599892d97021ccde0c29f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 5 Oct 2026 16:57:11 +0200 Subject: [PATCH 046/174] chore: bump kubectl to 1.37.1 in the worker image (#11538) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f4b2da66c8..b1b3f939e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -142,7 +142,7 @@ FROM ${DEBIAN_IMAGE} ARG TARGETPLATFORM ARG POWERSHELL_VERSION=7.5.0 -ARG KUBECTL_VERSION=1.36.2 +ARG KUBECTL_VERSION=1.37.1 ARG HELM_VERSION=3.21.4 # NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte ARG GO_VERSION=1.27.1 From 6c4e34cd0dd9b4329084214c40695f5915947cc2 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:01:25 +0200 Subject: [PATCH 047/174] fix: show the flow row edit button to builder operators (#11535) Co-authored-by: Claude Opus 5.5 --- .../components/common/table/FlowRow.svelte | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index e000ee25bc..ca8648baed 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -124,6 +124,8 @@ } let scheduleEditor: ScheduleEditor | undefined = $state(undefined) let flowHistory: FlowHistory | undefined = $state(undefined) + + let hideForOperator = $derived($userStore?.operator && !$operatorBuilderFlows) {#if menuOpen} @@ -191,25 +193,24 @@ {/snippet} {#snippet actions()} @@ -220,7 +221,6 @@ let { draft_only, path, archived } = flow let owner = isOwner(path, $userStore, $workspaceStore) const canEdit = flow.canWrite && showEditButton - const hideForOperator = $userStore?.operator && !$operatorBuilderFlows if (draft_only) { return [ ...selectMenuItems(rowSelection), From 3aee1815746f0116f5662f162ad1e234717af90d Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 5 Oct 2026 17:15:29 +0200 Subject: [PATCH 048/174] feat: bill team-plan service accounts as half a seat (#11298) Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- ...f33c1ce2e063aaf80fd155dbbbf99eb2fab3.json} | 10 ++++-- .../windmill-api-workspaces/src/workspaces.rs | 5 ++- backend/windmill-api/openapi.yaml | 6 ++++ backend/windmill-common/src/workspaces.rs | 15 ++++++--- .../tests/billing_workspace.rs | 31 +++++++++++++------ .../components/settings/PremiumInfo.svelte | 27 +++++++++++++--- 6 files changed, 72 insertions(+), 22 deletions(-) rename backend/.sqlx/{query-ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b.json => query-d61e8523269a26051079b0ee837bf33c1ce2e063aaf80fd155dbbbf99eb2fab3.json} (59%) diff --git a/backend/.sqlx/query-ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b.json b/backend/.sqlx/query-d61e8523269a26051079b0ee837bf33c1ce2e063aaf80fd155dbbbf99eb2fab3.json similarity index 59% rename from backend/.sqlx/query-ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b.json rename to backend/.sqlx/query-d61e8523269a26051079b0ee837bf33c1ce2e063aaf80fd155dbbbf99eb2fab3.json index 1930427b94..7b1e8d29cc 100644 --- a/backend/.sqlx/query-ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b.json +++ b/backend/.sqlx/query-d61e8523269a26051079b0ee837bf33c1ce2e063aaf80fd155dbbbf99eb2fab3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS \"developers!\",\n COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS \"operators!\"\n FROM usr WHERE workspace_id = $1", + "query": "SELECT\n COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS \"developers!\",\n COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS \"operators!\",\n COUNT(*) FILTER (WHERE is_service_account AND NOT disabled) AS \"service_accounts!\"\n FROM usr WHERE workspace_id = $1", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "operators!", "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "service_accounts!", + "type_info": "Int8" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + null, null, null ] }, - "hash": "ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b" + "hash": "d61e8523269a26051079b0ee837bf33c1ce2e063aaf80fd155dbbbf99eb2fab3" } diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index ac838d772b..123b1c8c9b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -744,13 +744,15 @@ async fn is_premium( #[derive(Serialize)] struct BillableSeatsResponse { - /// Both omitted when the seats counted are another workspace's: a fork member need not be a + /// All three omitted when the seats counted are another workspace's: a fork member need not be a /// member of the billing root, so the root's headcount is not theirs to read. The total is, /// since it is the divisor of the quota their own executions draw on. #[serde(skip_serializing_if = "Option::is_none")] developers: Option, #[serde(skip_serializing_if = "Option::is_none")] operators: Option, + #[serde(skip_serializing_if = "Option::is_none")] + service_accounts: Option, seats: i64, } @@ -780,6 +782,7 @@ async fn get_billable_seats( Ok(Json(BillableSeatsResponse { developers: own.then_some(counted.developers), operators: own.then_some(counted.operators), + service_accounts: own.then_some(counted.service_accounts), seats: counted.seats, })) } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7d4ec04987..377803e31e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4492,6 +4492,12 @@ paths: description: >- Omitted when the seats counted are another workspace's, as they are for a fork resolving to its billing root. + service_accounts: + type: integer + description: >- + Enabled service accounts, half a seat each like operators. Omitted when the + seats counted are another workspace's, as they are for a fork resolving to + its billing root. seats: type: integer required: diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 4751cb57be..7baad65a60 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -823,6 +823,7 @@ pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result { pub struct BillableSeats { pub developers: i64, pub operators: i64, + pub service_accounts: i64, pub seats: i64, } @@ -1043,9 +1044,10 @@ pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgre Ok(admits.unwrap_or(false)) } -/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service -/// accounts cannot log in and do not take a seat; a disabled member is not billed either. In a -/// workspace that granted operators builder rights, every operator counts as a developer. +/// Billable members of `w_id` and the seats they cost, as +/// `ceil(developers + (operators + service_accounts)/2)`. A service account is half a seat whatever +/// its role; a disabled member is not billed. In a workspace that granted operators builder rights, +/// every operator counts as a developer. /// /// The workspace is invoiced by a job outside this codebase that counts the same rows with its own /// SQL. The two must be changed together: this rule disagreeing with that one is what bills a @@ -1057,7 +1059,8 @@ pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result let row = sqlx::query!( r#"SELECT COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!", - COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS "operators!" + COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS "operators!", + COUNT(*) FILTER (WHERE is_service_account AND NOT disabled) AS "service_accounts!" FROM usr WHERE workspace_id = $1"#, w_id ) @@ -1072,7 +1075,9 @@ pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result Ok(BillableSeats { developers, operators, - seats: ((developers as f64) + 0.5 * (operators as f64)).ceil() as i64, + service_accounts: row.service_accounts, + seats: ((developers as f64) + 0.5 * ((operators + row.service_accounts) as f64)).ceil() + as i64, }) } diff --git a/backend/windmill-common/tests/billing_workspace.rs b/backend/windmill-common/tests/billing_workspace.rs index 04455318a6..0eddeabf53 100644 --- a/backend/windmill-common/tests/billing_workspace.rs +++ b/backend/windmill-common/tests/billing_workspace.rs @@ -101,21 +101,27 @@ async fn billing_workspace_survives_cycles(db: Pool) { #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn paid_seats_and_fork_count(db: Pool) { insert_ws(&db, "seat-root", None, false).await; - // 2 developers + 2 operators counted -> ceil(2 + 0.5*2) = 3. insert_member(&db, "seat-root", "dev1@w.dev", false, false, false).await; insert_member(&db, "seat-root", "dev2@w.dev", false, false, false).await; insert_member(&db, "seat-root", "op1@w.dev", true, false, false).await; insert_member(&db, "seat-root", "op2@w.dev", true, false, false).await; - // These must NOT count towards seats. The service account is a non-operator, so counting it - // would inflate the developer tally the invoice line is written from, not the operator one. - insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await; + // A service account is half a seat even with a developer role, counted apart from operators. insert_member(&db, "seat-root", "svc@w.dev", false, false, true).await; + // Not billed. + insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await; + insert_member(&db, "seat-root", "svc-disabled@w.dev", false, true, true).await; - assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 3); + // 2 developers + (2 operators + 1 service account)/2 -> ceil(3.5) = 4. + assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 4); let breakdown = billable_seats(&db, "seat-root").await.unwrap(); assert_eq!( - (breakdown.developers, breakdown.operators, breakdown.seats), - (2, 2, 3) + ( + breakdown.developers, + breakdown.operators, + breakdown.service_accounts, + breakdown.seats + ), + (2, 2, 1, 4) ); sqlx::query( @@ -128,9 +134,14 @@ async fn paid_seats_and_fork_count(db: Pool) { windmill_common::workspaces::invalidate_operator_rights_cache("seat-root"); let breakdown = billable_seats(&db, "seat-root").await.unwrap(); assert_eq!( - (breakdown.developers, breakdown.operators, breakdown.seats), - (4, 0, 4), - "builder rights bill every operator as a developer" + ( + breakdown.developers, + breakdown.operators, + breakdown.service_accounts, + breakdown.seats + ), + (4, 0, 1, 5), + "builder rights bill every operator as a developer, service accounts stay half a seat" ); insert_ws(&db, "seat-fork1", Some("seat-root"), false).await; diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index 4053313a17..5dbf80c910 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -31,6 +31,7 @@ status?: string developerNb: number operatorNb: number + serviceAccountNb: number seatsFromUsers: number seatsFromExtraComps: number usedSeats: number @@ -48,7 +49,7 @@ Team: [ `$10/mo per seat`, `Every seat includes 10 000 executions`, - `Every seat includes either 1 developer OR 2 operators` + `Every seat includes either 1 developer OR 2 operators or service accounts` ], Enterprise: [ `Dedicated and isolated database and workers available (EU/US/Asia)`, @@ -80,6 +81,7 @@ // which is exactly when the endpoint returns the breakdown. developerNb: billable.developers ?? 0, operatorNb: billable.operators ?? 0, + serviceAccountNb: billable.service_accounts ?? 0, seatsFromUsers, seatsFromExtraComps, usedSeats: seatsFromUsers + seatsFromExtraComps @@ -277,17 +279,34 @@
+ + +
+
Service accounts
+

+ Enabled service accounts, half a seat each like operators, whatever their role. +

+
+
+ +
+ {formatNumber(premiumInfo.serviceAccountNb)} +
+
+
Seats from users

- 1 developer = 1 seat, 2 operators = 1 seat. + 1 developer = 1 seat, 2 operators or service accounts = 1 seat.

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

From 85248eee786b633def40c42b6007fc7b907fb09e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 5 Oct 2026 17:18:43 +0200 Subject: [PATCH 049/174] fix: disable postgres triggers whose replication slot was invalidated (#11536) Co-authored-by: Claude Opus 5.5 --- .../windmill-trigger-postgres/src/listener.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/windmill-trigger-postgres/src/listener.rs b/backend/windmill-trigger-postgres/src/listener.rs index 876b75661e..c99043ae9b 100644 --- a/backend/windmill-trigger-postgres/src/listener.rs +++ b/backend/windmill-trigger-postgres/src/listener.rs @@ -136,7 +136,7 @@ impl PostgresSimpleClient { /// Resolves the Postgres resource, validates that the configured publication and /// replication slot still exist, and opens a fresh logical replication stream. /// -/// Returns `Error::BadConfig` when the publication or slot is missing, or the +/// Returns `Error::BadConfig` when the publication or slot is missing or invalidated, or the /// data table is under roles (unrecoverable misconfigurations). Any other error is treated as transient /// (connection refused, network interruption, ...) and is retried by the caller. /// The resource is re-resolved on every call so credential rotations are picked @@ -183,18 +183,33 @@ async fn connect_logical_replication_stream( )); } + // `invalidation_reason` (PG 17+) and `conflicting` (PG 16) are read through jsonb so the + // query still runs on versions that lack those columns. let replication_slot = client .execute_query(&format!( - "SELECT slot_name FROM pg_replication_slots WHERE slot_name = {}", + r#"SELECT COALESCE( + j->>'invalidation_reason', + CASE WHEN j->>'wal_status' = 'lost' THEN 'wal_removed' + WHEN j->>'conflicting' = 'true' THEN 'conflict with recovery' END + ) FROM pg_replication_slots s, to_jsonb(s) j WHERE slot_name = {}"#, quote_literal(replication_slot_name) )) .await .map_err(to_anyhow)?; - if !replication_slot.row_exist() { + let Some(slot) = replication_slot.iter().find_map(|m| match m { + SimpleQueryMessage::Row(row) => Some(row), + _ => None, + }) else { return Err(Error::BadConfig( ERROR_REPLICATION_SLOT_NOT_EXISTS.to_string(), )); + }; + + if let Some(reason) = slot.get(0) { + return Err(Error::BadConfig(format!( + "The replication slot `{replication_slot_name}` associated with this trigger has been invalidated by PostgreSQL (reason: `{reason}`). Changes since the invalidation cannot be replayed. Drop the slot (Advanced tab, slots) and save the trigger again to recreate it, or select another slot." + ))); } client From af42c471efa2873dc0f35304de0570873bf7d065 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 5 Oct 2026 17:27:00 +0200 Subject: [PATCH 050/174] chore(main): release 1.824.0 (#11520) * chore(main): release 1.824.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 25 +++ backend/Cargo.lock | 166 +++++++++--------- 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, 155 insertions(+), 130 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0f25f57894..bb3b3d5c53 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.823.1" + ".": "1.824.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index cddb164d9b..820ea01ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [1.824.0](https://github.com/windmill-labs/windmill/compare/v1.823.1...v1.824.0) (2026-10-05) + + +### Features + +* add an AI decision flow step (TypeSafe's Jev, Cloudflare's Clef) ([#11474](https://github.com/windmill-labs/windmill/issues/11474)) ([3cba8a7](https://github.com/windmill-labs/windmill/commit/3cba8a7e4cca3ef20ba73a7dad79d892bdc9fed8)) +* add notion oauth provider ([#11528](https://github.com/windmill-labs/windmill/issues/11528)) ([8b2dc1e](https://github.com/windmill-labs/windmill/commit/8b2dc1e46c7f4e3859263f18b686ce6f3ce6fd30)) +* **ai-chat:** show per-file diffs for raw app edits ([#11521](https://github.com/windmill-labs/windmill/issues/11521)) ([ec0883a](https://github.com/windmill-labs/windmill/commit/ec0883a627b06b6ba2a393d5cf66ddf6ebc4cd0d)) +* bill team-plan service accounts as half a seat ([#11298](https://github.com/windmill-labs/windmill/issues/11298)) ([3aee181](https://github.com/windmill-labs/windmill/commit/3aee1815746f0116f5662f162ad1e234717af90d)) +* card layout and load-more for the audit logs page ([#11507](https://github.com/windmill-labs/windmill/issues/11507)) ([af1a29a](https://github.com/windmill-labs/windmill/commit/af1a29a3cb33386f61a759ec0dd5eec0ccefefc2)) +* offer audit-log and drive-metadata scopes for google oauth connects ([#11527](https://github.com/windmill-labs/windmill/issues/11527)) ([c4e7c71](https://github.com/windmill-labs/windmill/commit/c4e7c714e455f958d9a927c4bc4eb23256324780)) +* offer slack read scopes for files, user groups, emoji and dms ([#11526](https://github.com/windmill-labs/windmill/issues/11526)) ([b5fda7d](https://github.com/windmill-labs/windmill/commit/b5fda7d5af7105fde69beb68410984350af2ac7e)) + + +### Bug Fixes + +* bound s3 ai attachment downloads before encoding ([#11534](https://github.com/windmill-labs/windmill/issues/11534)) ([92fbfc0](https://github.com/windmill-labs/windmill/commit/92fbfc03d219544c16e8f7db47a547f9ff402c27)) +* collapse queued and running chat tool calls into a waiting row ([#11523](https://github.com/windmill-labs/windmill/issues/11523)) ([4f30982](https://github.com/windmill-labs/windmill/commit/4f30982ce3f7abc64a31890f8e88c31c1c2e0803)) +* disable postgres triggers whose replication slot was invalidated ([#11536](https://github.com/windmill-labs/windmill/issues/11536)) ([85248ee](https://github.com/windmill-labs/windmill/commit/85248eee786b633def40c42b6007fc7b907fb09e)) +* evaluate field conditions and step test inputs in QuickJS ([#11487](https://github.com/windmill-labs/windmill/issues/11487)) ([c962d37](https://github.com/windmill-labs/windmill/commit/c962d3766b1a250ba960e413556890d5919ebc94)) +* flush proxied responses before the dev supervisor closes the client ([#11530](https://github.com/windmill-labs/windmill/issues/11530)) ([cefb195](https://github.com/windmill-labs/windmill/commit/cefb1953764e714d79556e2f8065cfbec78acb86)) +* label the default slack scopes and list them first in the connect dialog ([#11529](https://github.com/windmill-labs/windmill/issues/11529)) ([e76781c](https://github.com/windmill-labs/windmill/commit/e76781cc2709fda08403e14c1a0a01957e430db1)) +* run each ai eval model in its own job and let the docs tools reach the backend ([#11533](https://github.com/windmill-labs/windmill/issues/11533)) ([222dad1](https://github.com/windmill-labs/windmill/commit/222dad1d8cc9cbb6925fdff709e00a49bb63bc4a)) +* show the flow row edit button to builder operators ([#11535](https://github.com/windmill-labs/windmill/issues/11535)) ([6c4e34c](https://github.com/windmill-labs/windmill/commit/6c4e34cd0dd9b4329084214c40695f5915947cc2)) + ## [1.823.1](https://github.com/windmill-labs/windmill/compare/v1.823.0...v1.823.1) (2026-10-05) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2b17505c10..58e2a94cfb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -9563,9 +9563,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -14195,9 +14195,9 @@ dependencies = [ [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-bidi" @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.823.1" +version = "1.824.0" dependencies = [ "async-stream", "async-trait", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "argon2", @@ -15105,7 +15105,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15145,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15171,7 +15171,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.823.1" +version = "1.824.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15198,7 +15198,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15220,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15259,7 +15259,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15303,7 +15303,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-nats", @@ -15353,7 +15353,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15378,7 +15378,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15406,7 +15406,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15428,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15448,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15490,7 +15490,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.823.1" +version = "1.824.0" dependencies = [ "lazy_static", "serde", @@ -15531,7 +15531,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.823.1" +version = "1.824.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15555,7 +15555,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.823.1" +version = "1.824.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15608,7 +15608,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.823.1" +version = "1.824.0" dependencies = [ "chrono", "lazy_static", @@ -15622,7 +15622,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15641,7 +15641,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.823.1" +version = "1.824.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15755,7 +15755,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.823.1" +version = "1.824.0" dependencies = [ "chrono", "futures", @@ -15775,7 +15775,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.823.1" +version = "1.824.0" dependencies = [ "regex", "serde", @@ -15792,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15819,7 +15819,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "futures", @@ -15836,7 +15836,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.823.1" +version = "1.824.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15852,7 +15852,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -15873,7 +15873,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -15904,7 +15904,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "arc-swap", @@ -15929,7 +15929,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-stream", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "futures", @@ -15983,7 +15983,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.823.1" +version = "1.824.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15992,7 +15992,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -16004,7 +16004,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde_json", @@ -16016,7 +16016,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "gosyn", @@ -16028,7 +16028,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -16040,7 +16040,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde_json", @@ -16052,7 +16052,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "nu-parser", @@ -16063,7 +16063,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16086,7 +16086,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-recursion", @@ -16119,7 +16119,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde_json", @@ -16131,7 +16131,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -16145,7 +16145,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16162,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -16175,7 +16175,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde", @@ -16187,7 +16187,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -16205,7 +16205,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16221,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16237,7 +16237,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -16251,7 +16251,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-recursion", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "const_format", @@ -16330,7 +16330,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.823.1" +version = "1.824.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16341,7 +16341,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-recursion", @@ -16376,7 +16376,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16400,7 +16400,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16433,7 +16433,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16460,7 +16460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16493,7 +16493,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16513,7 +16513,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16547,7 +16547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16584,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16607,7 +16607,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16631,7 +16631,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-nats", @@ -16655,7 +16655,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16718,7 +16718,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-trait", @@ -16743,7 +16743,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16762,7 +16762,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-once-cell", @@ -16881,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.823.1" +version = "1.824.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e8eef06ba6..df8c3db272 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.823.1" +version = "1.824.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.823.1" +version = "1.824.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 612e2eed89..f53664557b 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.823.1" +version = "1.824.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.823.1" +version = "1.824.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.823.1" +version = "1.824.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.823.1" +version = "1.824.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 672e4c9920..1482b8d854 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.823.1" +version = "1.824.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 377803e31e..3ffac89516 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.823.1 + version: 1.824.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 246bc3a740..fe3b216d7c 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.823.1"; +export const VERSION = "v1.824.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 d4fc8e642f..06f3ed4ce3 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.823.1", + "version": "1.824.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.823.1", + "version": "1.824.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 59fb868ef9..c727309a7a 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.823.1", + "version": "1.824.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 5c88f85b14..e971547e6b 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.823.1"; +export const VERSION = "1.824.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0334f5e756..affd5aee62 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.823.1", + "version": "1.824.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.823.1", + "version": "1.824.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 04ac888374..a14275bf4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.823.1", + "version": "1.824.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 5d6cf2b3f9..8ed25f670d 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.823.1" +wmill = ">=1.824.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9ba817f772..034be97ea4 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.823.1 + version: 1.824.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index c4deec8281..6382d4d2c9 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.823.1' + ModuleVersion = '1.824.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 9826d7ad8c..876136da05 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.823.1" +version = "1.824.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 00e9e015b7..0bb87df486 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.823.1", + "version": "1.824.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 79141f7dfc..6dbbdb9e51 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.823.1", + "version": "1.824.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 0125cfae83..8b32b14628 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.823.1 +1.824.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index f08e6270b0..19aa53ca6d 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.823.1", + "version": "1.824.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.823.1", + "version": "1.824.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 1cc26b27f9..4c4fd7b414 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.823.1", + "version": "1.824.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 0908dee897e0f87b97418a23ede3662d96270340 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 6 Oct 2026 00:20:36 +0200 Subject: [PATCH 051/174] fix: keep monaco's diff engine out of the app shell bundle (#11542) Co-authored-by: Claude Opus 5.5 (1M context) --- .../copilot/chat/appFileEditorLang.ts | 25 +++++++++++++++++++ .../components/copilot/chat/global/core.ts | 2 +- .../components/copilot/chat/toolCodeDiff.ts | 25 +------------------ 3 files changed, 27 insertions(+), 25 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/appFileEditorLang.ts diff --git a/frontend/src/lib/components/copilot/chat/appFileEditorLang.ts b/frontend/src/lib/components/copilot/chat/appFileEditorLang.ts new file mode 100644 index 0000000000..bb4c42325d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/appFileEditorLang.ts @@ -0,0 +1,25 @@ +// Kept apart from toolCodeDiff.ts, which imports Monaco's diff engine: the chat core runs in +// the app shell and must not statically reach Monaco. +const LANGUAGE_BY_APP_FILE_EXTENSION: Record = { + ts: 'typescript', + tsx: 'typescript', + mts: 'typescript', + js: 'javascript', + jsx: 'javascript', + mjs: 'javascript', + py: 'python', + css: 'css', + html: 'xml', + svelte: 'xml', + vue: 'xml', + json: 'json', + yaml: 'yaml', + yml: 'yaml' +} + +// A raw app's backend runnables are named `backend//main.ts|py`, so their extension +// picks the language like any frontend file's. +export function appFileEditorLang(filePath: string | undefined): string { + const extension = filePath?.split('/').pop()?.split('.').slice(1).pop()?.toLowerCase() + return (extension && LANGUAGE_BY_APP_FILE_EXTENSION[extension]) ?? 'plaintext' +} diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 90289ca93f..f49e105be5 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -149,7 +149,7 @@ import { type ToolDisplayAction } from '../shared' import { scriptLangToEditorLang } from '$lib/scripts' -import { appFileEditorLang } from '../toolCodeDiff' +import { appFileEditorLang } from '../appFileEditorLang' import { searchDocsTool, readDocsPageTool } from '../docs/core' import { createDbSchemaTool } from '../script/core' import type { ContextElement } from '../context' diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts index 5112dc43d9..7fc51827e4 100644 --- a/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts @@ -1,6 +1,7 @@ // This is Monaco's advanced diff engine; it supplies the same multi-line inner ranges. import { DefaultLinesDiffComputer } from '@codingame/monaco-vscode-api/vscode/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer' import type { ToolCodeDiff, ToolDisplayMessage } from './shared' +import { appFileEditorLang } from './appFileEditorLang' // Builds a diff from a call's own arguments, for the moments the saved `codeDiff` does not // exist: while the arguments stream, when the call failed, and for transcripts saved before @@ -46,30 +47,6 @@ const ARGS_DIFF_BY_TOOL: Record ToolCodeDiff | unde } } -const LANGUAGE_BY_APP_FILE_EXTENSION: Record = { - ts: 'typescript', - tsx: 'typescript', - mts: 'typescript', - js: 'javascript', - jsx: 'javascript', - mjs: 'javascript', - py: 'python', - css: 'css', - html: 'xml', - svelte: 'xml', - vue: 'xml', - json: 'json', - yaml: 'yaml', - yml: 'yaml' -} - -// A raw app's backend runnables are named `backend//main.ts|py`, so their extension -// picks the language like any frontend file's. -export function appFileEditorLang(filePath: string | undefined): string { - const extension = filePath?.split('/').pop()?.split('.').slice(1).pop()?.toLowerCase() - return (extension && LANGUAGE_BY_APP_FILE_EXTENSION[extension]) ?? 'plaintext' -} - function argumentString(params: unknown, key: string): string | undefined { if (params && typeof params === 'object') { const value = (params as Record)[key] From 0abc0ac0c4a6a501583b7101a952706a57eed124 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 6 Oct 2026 00:24:43 +0200 Subject: [PATCH 052/174] chore(main): release 1.824.1 (#11543) * chore(main): release 1.824.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 + backend/Cargo.lock | 162 +++++++++--------- 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, 135 insertions(+), 128 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bb3b3d5c53..adcb3ce942 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.824.0" + ".": "1.824.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 820ea01ef9..3463ea1692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.824.1](https://github.com/windmill-labs/windmill/compare/v1.824.0...v1.824.1) (2026-10-05) + + +### Bug Fixes + +* keep monaco's diff engine out of the app shell bundle ([#11542](https://github.com/windmill-labs/windmill/issues/11542)) ([0908dee](https://github.com/windmill-labs/windmill/commit/0908dee897e0f87b97418a23ede3662d96270340)) + ## [1.824.0](https://github.com/windmill-labs/windmill/compare/v1.823.1...v1.824.0) (2026-10-05) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 58e2a94cfb..e7ea4b8576 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -10595,9 +10595,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.5.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e66877136e969d5a2d561ee5841b17717a0619aebd64abb47a2a2f46e34683" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.824.0" +version = "1.824.1" dependencies = [ "async-stream", "async-trait", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "argon2", @@ -15105,7 +15105,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15145,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15171,7 +15171,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.824.0" +version = "1.824.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15198,7 +15198,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15220,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15259,7 +15259,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15303,7 +15303,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-nats", @@ -15353,7 +15353,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15378,7 +15378,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15406,7 +15406,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15428,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15448,7 +15448,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15490,7 +15490,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.824.0" +version = "1.824.1" dependencies = [ "lazy_static", "serde", @@ -15531,7 +15531,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.824.0" +version = "1.824.1" dependencies = [ "argon2", "axum 0.8.9", @@ -15555,7 +15555,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.824.0" +version = "1.824.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15608,7 +15608,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.824.0" +version = "1.824.1" dependencies = [ "chrono", "lazy_static", @@ -15622,7 +15622,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15641,7 +15641,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.824.0" +version = "1.824.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -15755,7 +15755,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.824.0" +version = "1.824.1" dependencies = [ "chrono", "futures", @@ -15775,7 +15775,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.824.0" +version = "1.824.1" dependencies = [ "regex", "serde", @@ -15792,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15819,7 +15819,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "futures", @@ -15836,7 +15836,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.824.0" +version = "1.824.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15852,7 +15852,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -15873,7 +15873,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -15904,7 +15904,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "arc-swap", @@ -15929,7 +15929,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-stream", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "futures", @@ -15983,7 +15983,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.824.0" +version = "1.824.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -15992,7 +15992,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -16004,7 +16004,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde_json", @@ -16016,7 +16016,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "gosyn", @@ -16028,7 +16028,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -16040,7 +16040,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde_json", @@ -16052,7 +16052,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "nu-parser", @@ -16063,7 +16063,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16086,7 +16086,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-recursion", @@ -16119,7 +16119,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde_json", @@ -16131,7 +16131,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -16145,7 +16145,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16162,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -16175,7 +16175,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde", @@ -16187,7 +16187,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -16205,7 +16205,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16221,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16237,7 +16237,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -16251,7 +16251,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-recursion", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "const_format", @@ -16330,7 +16330,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.824.0" +version = "1.824.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16341,7 +16341,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-recursion", @@ -16376,7 +16376,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16400,7 +16400,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16433,7 +16433,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16460,7 +16460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16493,7 +16493,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16513,7 +16513,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16547,7 +16547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16584,7 +16584,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16607,7 +16607,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16631,7 +16631,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-nats", @@ -16655,7 +16655,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16718,7 +16718,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-trait", @@ -16743,7 +16743,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16762,7 +16762,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-once-cell", @@ -16881,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.824.0" +version = "1.824.1" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index df8c3db272..c176701794 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.824.0" +version = "1.824.1" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.824.0" +version = "1.824.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index f53664557b..6da93e45f6 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.824.0" +version = "1.824.1" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.824.0" +version = "1.824.1" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.824.0" +version = "1.824.1" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.824.0" +version = "1.824.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 1482b8d854..91cf4b160d 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.824.0" +version = "1.824.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3ffac89516..4cdbe4ee3d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.824.0 + version: 1.824.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index fe3b216d7c..32c024be93 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.824.0"; +export const VERSION = "v1.824.1"; 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 06f3ed4ce3..27a714c352 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.824.0", + "version": "1.824.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.824.0", + "version": "1.824.1", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index c727309a7a..6a3fa80bc4 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.824.0", + "version": "1.824.1", "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 e971547e6b..8fb9720c49 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.824.0"; +export const VERSION = "1.824.1"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index affd5aee62..82ee31fe37 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.824.0", + "version": "1.824.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.824.0", + "version": "1.824.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index a14275bf4a..7c2d03abfb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.824.0", + "version": "1.824.1", "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 8ed25f670d..9ed028000e 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.824.0" +wmill = ">=1.824.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 034be97ea4..0571fe38bd 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.824.0 + version: 1.824.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 6382d4d2c9..c0760c632c 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.824.0' + ModuleVersion = '1.824.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 876136da05..dff93a790b 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.824.0" +version = "1.824.1" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 0bb87df486..03652c601e 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.824.0", + "version": "1.824.1", "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 6dbbdb9e51..1fc811f6e7 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.824.0", + "version": "1.824.1", "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 8b32b14628..85392afd57 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.824.0 +1.824.1 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 19aa53ca6d..a136cc1653 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.824.0", + "version": "1.824.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.824.0", + "version": "1.824.1", "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 4c4fd7b414..4e647a5959 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.824.0", + "version": "1.824.1", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 18e44d31742b8d16852b8c6ae663979666e064d7 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 6 Oct 2026 08:08:13 +0200 Subject: [PATCH 053/174] feat: say a canceled team plan is canceled, not past due (#11539) A team plan canceled mid-month now keeps premium until the 1st, capped at the executions its final invoice paid for. That reuses the past-due cap, so the job refusal and the UI toast said "your last invoice is unpaid". Word both for a canceled plan, and show it on the plan settings page. Co-authored-by: Claude Opus 5.5 --- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi-deref.json | 4 ++++ backend/windmill-api/openapi-deref.yaml | 3 +++ backend/windmill-api/openapi.yaml | 3 +++ backend/windmill-queue/src/jobs.rs | 17 ++++++++++++++--- .../lib/components/settings/PremiumInfo.svelte | 13 +++++++++++-- .../src/routes/(root)/(logged)/+layout.svelte | 10 +++++++++- 7 files changed, 45 insertions(+), 7 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f00c5de5f8..24c44aa112 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -154cbccb6b8d47bf662b5507882917b5933d5a45 +b5a8e3631f657fbb0a7b4b4ef89ddb9ed2c43b1d diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 3cba2b7da6..8dae1bafcd 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -5424,6 +5424,10 @@ "is_past_due": { "type": "boolean" }, + "is_canceled": { + "type": "boolean", + "description": "the team plan was canceled this month; it stays premium until the month ends, capped at max_tolerated_executions" + }, "max_tolerated_executions": { "type": "number" } diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index d5a14a717d..439e4d11a0 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -5286,6 +5286,9 @@ paths: type: string is_past_due: type: boolean + is_canceled: + type: boolean + description: the team plan was canceled this month; it stays premium until the month ends, capped at max_tolerated_executions max_tolerated_executions: type: number required: diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4cdbe4ee3d..221a3fb886 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4535,6 +4535,9 @@ paths: type: string is_past_due: type: boolean + is_canceled: + type: boolean + description: the team plan was canceled this month; it stays premium until the month ends, capped at max_tolerated_executions max_tolerated_executions: type: number required: diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index e48ff19713..49aca2cba7 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -6261,9 +6261,20 @@ async fn push_inner<'c, 'd>( if team_plan_status.max_tolerated_executions.is_none() || workspace_usage > team_plan_status.max_tolerated_executions.unwrap() { - return Err(error::Error::QuotaExceeded(format!( - "Workspace {workspace_id} team plan is past due and isn't allowed to run any more jobs. Please fix your payment method in the workspace settings." - ))); + // A canceled plan reuses the past-due cap for its last month. Only read + // here, on the refusal path, to word it. + let plan_canceled = sqlx::query_scalar::<_, bool>( + "SELECT COALESCE(plan = 'team_canceled', false) FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(&billing_w_id) + .fetch_optional(db) + .await? + .unwrap_or(false); + return Err(error::Error::QuotaExceeded(if plan_canceled { + format!("Workspace {workspace_id} team plan was canceled and has used the executions paid for this month. Jobs can run again on the free plan from the 1st, or right away by subscribing again in the workspace settings.") + } else { + format!("Workspace {workspace_id} team plan is past due and isn't allowed to run any more jobs. Please fix your payment method in the workspace settings.") + })); } } else { if workspace_usage > MAX_FREE_EXECS diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index 5dbf80c910..b96ab5e22b 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -37,6 +37,7 @@ usedSeats: number owner: string is_past_due: boolean + is_canceled?: boolean max_tolerated_executions?: number } | undefined = $state(undefined) @@ -164,11 +165,19 @@
- {#if premiumInfo?.status === 'past_due'} + {#if premiumInfo?.is_canceled} +

+ Your final invoice has been sent. The team plan stays active until the end of the month, up + to the {premiumInfo.max_tolerated_executions?.toLocaleString('en-US')} executions paid for, + then this workspace moves to the free plan. +

+ {:else if premiumInfo?.status === 'past_due'}

{#if premiumInfo.max_tolerated_executions === undefined || premiumInfo.usage > premiumInfo.max_tolerated_executions} Your last invoice is unpaid, you cannot run any more jobs. Please update your payment diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index e6d834c816..1d4bc9de19 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -741,7 +741,15 @@ workspace, skipSubscriptionFetch: true // won't load subscription status from stripe but only the past due status from db }) - if (premiumInfo.is_past_due) { + if (premiumInfo.is_canceled) { + // Its last month runs on a cap too, but nothing is unpaid: only speak up once it bites + if ((premiumInfo.usage ?? 0) > (premiumInfo.max_tolerated_executions ?? 0)) { + sendUserToast( + 'Your team plan was canceled and this month\'s paid executions are used up. Jobs can run again on the free plan from the 1st, or subscribe again in the workspace settings.', + true + ) + } + } else if (premiumInfo.is_past_due) { if ( premiumInfo.max_tolerated_executions === undefined || (premiumInfo.usage ?? 0) > premiumInfo.max_tolerated_executions From 2bf9cfcd59e1101695160524c31a4da1a41699dc Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 6 Oct 2026 10:57:54 +0200 Subject: [PATCH 054/174] fix: search slow runs filters by time windows instead of timing out (#11549) * fix: search slow runs filters by time windows instead of timing out * fix: cancel a pending runs page when its listing is reset * fix: keep runs scan windows disjoint at microsecond precision * fix: race the single runs request against the window scan * fix: list the cursor window when a runs scan starts at the oldest job * fix: show the runs history scan as an info alert * fix: show runs scan progress as a bar and keep the session button on the searchbar row * fix: stop a runs scan whose windows are no faster when narrower * fix: right-align the runs searchbar row when the header wraps * fix: keep the runs scan stop button inside a narrow list pane --- backend/windmill-api/openapi.yaml | 25 ++ backend/windmill-api/src/jobs.rs | 36 ++ .../lib/components/BatchLoadProgress.svelte | 10 +- frontend/src/lib/components/RunsPage.svelte | 82 +++-- .../components/runs/jobsWindowScan.test.ts | 140 ++++++++ .../src/lib/components/runs/jobsWindowScan.ts | 139 ++++++++ .../components/runs/useJobsLoader.svelte.ts | 317 +++++++++++++++--- 7 files changed, 667 insertions(+), 82 deletions(-) create mode 100644 frontend/src/lib/components/runs/jobsWindowScan.test.ts create mode 100644 frontend/src/lib/components/runs/jobsWindowScan.ts diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 221a3fb886..62b0e88b50 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -16536,6 +16536,31 @@ paths: required: - database_length + /w/{workspace}/jobs/oldest: + get: + summary: get the creation date of the oldest job + operationId: getOldestJob + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: all_workspaces + description: consider jobs from all workspaces (only valid if request come from the `admins` workspace) + in: query + schema: + type: boolean + responses: + "200": + description: creation date of the oldest job, absent when there is none + content: + application/json: + schema: + type: object + properties: + created_at: + type: string + format: date-time + /w/{workspace}/jobs/completed/count: get: summary: get completed count diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index ac3f06ef81..6ce04243d3 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -292,6 +292,7 @@ pub fn workspaced_service() -> Router { ) .route("/run/dynamic_select", post(run_dynamic_select)) .route("/list", get(list_jobs)) + .route("/oldest", get(get_oldest_job)) .route("/asset_dispatch_edges", get(list_asset_dispatch_edges)) .route( "/list_selected_job_groups", @@ -4728,6 +4729,41 @@ async fn count_completed_jobs( )) } +#[derive(Deserialize)] +struct OldestJobQuery { + all_workspaces: Option, +} + +#[derive(Serialize)] +struct OldestJob { + #[serde(skip_serializing_if = "Option::is_none")] + created_at: Option>, +} + +/// Where the job history starts, whatever the caller may see of it: a client scanning the +/// runs backwards in time windows needs a bound that row-level visibility cannot make slow. +async fn get_oldest_job( + Extension(db): Extension, + Path(w_id): Path, + Query(q): Query, +) -> error::JsonResult { + let created_at = if w_id == "admins" && q.all_workspaces.unwrap_or(false) { + // One index probe per workspace: a plain min() over the table has no index to walk. + sqlx::query_scalar( + "SELECT min(o.created_at) FROM workspace w CROSS JOIN LATERAL \ + (SELECT min(created_at) AS created_at FROM v2_job WHERE workspace_id = w.id) o", + ) + .fetch_one(&db) + .await? + } else { + sqlx::query_scalar("SELECT min(created_at) FROM v2_job WHERE workspace_id = $1") + .bind(&w_id) + .fetch_one(&db) + .await? + }; + Ok(Json(OldestJob { created_at })) +} + lazy_static::lazy_static! { /// 0 keeps the connection-wide statement_timeout. static ref LIST_JOBS_STATEMENT_TIMEOUT_SECS: u64 = diff --git a/frontend/src/lib/components/BatchLoadProgress.svelte b/frontend/src/lib/components/BatchLoadProgress.svelte index 16cf2d7212..a41151b210 100644 --- a/frontend/src/lib/components/BatchLoadProgress.svelte +++ b/frontend/src/lib/components/BatchLoadProgress.svelte @@ -6,6 +6,8 @@ loaded: number total: number itemsLabel: string + /** Replaces the default "Loading … of …" text, for progress that is not a count of items. */ + label?: string batchSize?: number | null /** Largest batch the caller can fetch in one request, when that is below the page size. */ batchSizeCap?: number @@ -17,6 +19,7 @@ loaded, total, itemsLabel, + label, batchSize = null, batchSizeCap, onBatchSizeChange, @@ -30,7 +33,12 @@

- Loading {itemsLabel}: {loaded} of {total}... + {#if label} + + {label} + {:else} + Loading {itemsLabel}: {loaded} of {total}... + {/if}
- - - pageHref(RUNS_PATH) + window.location.search, - workspaceId: $workspaceStore ?? undefined - }} - btnProps={{ unifiedSize: 'md' }} - /> + +
+ + + +
+ pageHref(RUNS_PATH) + window.location.search, + workspaceId: $workspaceStore ?? undefined + }} + btnProps={{ unifiedSize: 'md' }} + /> +
+
@@ -939,7 +949,21 @@
-
+
+ {#if scanProgress} +
+ +
+ jobsLoader.stopBatchLoading()} + /> +
+
+ {/if} {#if batchProgress}
{ + windows.push(w) + const lo = Date.parse(w.after) + const hi = w.before ? Date.parse(w.before) : Infinity + if (hi - lo > slowOver) return new CancelablePromise(() => {}) + const rows = at + .filter((t) => t >= lo && t <= hi) + .sort((a, b) => b - a) + .slice(0, w.limit) + .map(job) + return new CancelablePromise((resolve) => resolve(rows)) + } + return { windows, fetchWindow } +} + +describe('scanJobWindows', () => { + it('lists every match down to the oldest job, in order, without a gap between windows', async () => { + const at = [NOW - 2 * HOUR, NOW - 30 * HOUR, NOW - 200 * HOUR] + const { windows, fetchWindow } = history(at) + const seen: Job[] = [] + const wentToEnd = await scanJobWindows({ + before: new Date(NOW).toISOString(), + after: null, + pageSize: 10, + oldest: async () => new Date(NOW - 240 * HOUR).toISOString(), + fetchWindow, + onWindow: (jobs) => seen.push(...jobs) + }) + expect(wentToEnd).toBe(true) + expect(seen.map((j) => j.id)).toEqual(at.map((t) => `j${t}`)) + expect(windows.at(-1)!.after).toBe(new Date(NOW - 240 * HOUR).toISOString()) + }) + + it('counts a job created exactly on a window bound once', async () => { + // The first window is an hour wide, so its lower bound is the first job's creation time. + const at = [NOW - HOUR, NOW - 2 * HOUR, NOW - 100 * HOUR] + const { fetchWindow } = history(at) + const seen: Job[] = [] + const wentToEnd = await scanJobWindows({ + before: new Date(NOW).toISOString(), + after: new Date(NOW - 240 * HOUR).toISOString(), + pageSize: 2, + oldest: async () => undefined, + fetchWindow, + onWindow: (jobs) => seen.push(...jobs) + }) + expect(wentToEnd).toBe(false) + expect(seen.map((j) => j.id)).toEqual([`j${NOW - HOUR}`, `j${NOW - 2 * HOUR}`]) + }) + + it('still lists the window of a cursor that sits on the oldest job', async () => { + const oldest = NOW - 240 * HOUR + const { windows, fetchWindow } = history([oldest]) + const seen: Job[] = [] + const before = new Date(oldest).toISOString().replace('Z', '500Z') + const wentToEnd = await scanJobWindows({ + before, + after: null, + pageSize: 10, + oldest: async () => new Date(oldest).toISOString(), + fetchWindow, + onWindow: (jobs) => seen.push(...jobs) + }) + expect(wentToEnd).toBe(true) + expect(windows).toEqual([{ before, after: new Date(oldest).toISOString(), limit: 10 }]) + expect(seen.map((j) => j.id)).toEqual([`j${oldest}`]) + }) + + it('stops as soon as the page is full, asking each window only for what is missing', async () => { + const at = [NOW - 0.5 * HOUR, NOW - 2 * HOUR, NOW - 3 * HOUR, NOW - 100 * HOUR] + const { windows, fetchWindow } = history(at) + const seen: Job[] = [] + const wentToEnd = await scanJobWindows({ + before: new Date(NOW).toISOString(), + after: new Date(NOW - 240 * HOUR).toISOString(), + pageSize: 3, + oldest: async () => undefined, + fetchWindow, + onWindow: (jobs) => seen.push(...jobs) + }) + expect(wentToEnd).toBe(false) + expect(seen).toHaveLength(3) + expect(windows.map((w) => w.limit)).toEqual([3, 2]) + }) + + it('gives up when narrower windows are no faster', async () => { + // Every window costs the same whatever its width, as with an index that ignores the bounds. + const windows: JobWindow[] = [] + const fetchWindow = (w: JobWindow) => { + windows.push(w) + return new CancelablePromise((resolve) => setTimeout(() => resolve([]), 30)) + } + await expect( + scanJobWindows({ + before: new Date(NOW).toISOString(), + after: new Date(NOW - 240 * HOUR).toISOString(), + pageSize: 10, + oldest: async () => undefined, + fetchWindow, + onWindow: () => {}, + targetMs: 10, + patienceMs: 1000 + }) + ).rejects.toThrow('too slow') + expect(windows.length).toBeLessThan(10) + }) + + it('retries a window that does not answer in time with a narrower one', async () => { + const at = [NOW - 0.2 * HOUR, NOW - 0.9 * HOUR] + const { windows, fetchWindow } = history(at, 0.5 * HOUR) + const seen: Job[] = [] + const wentToEnd = await scanJobWindows({ + before: new Date(NOW).toISOString(), + after: new Date(NOW - HOUR).toISOString(), + pageSize: 10, + oldest: async () => undefined, + fetchWindow, + onWindow: (jobs) => seen.push(...jobs), + patienceMs: 5 + }) + expect(wentToEnd).toBe(true) + expect(seen.map((j) => j.id)).toEqual(at.map((t) => `j${t}`)) + expect(windows.length).toBeGreaterThan(2) + }) +}) diff --git a/frontend/src/lib/components/runs/jobsWindowScan.ts b/frontend/src/lib/components/runs/jobsWindowScan.ts new file mode 100644 index 0000000000..c273edbceb --- /dev/null +++ b/frontend/src/lib/components/runs/jobsWindowScan.ts @@ -0,0 +1,139 @@ +import { CancelablePromise, type Job } from '$lib/gen' + +// A window is sized so that listing it takes about this long: short enough that the scan keeps +// reporting progress, long enough that a sparse history is not crossed one round trip at a time. +const TARGET_MS = 2000 +// Past this the window is dropped and retried narrower. The server keeps running a dropped +// query until its own statement timeout, so it has to be rare: a window grows by at most +// MAX_STEP per round, which keeps a well-sized one under PATIENCE_MS = MAX_STEP * TARGET_MS. +const MAX_STEP = 4 +const PATIENCE_MS = MAX_STEP * TARGET_MS +const INITIAL_WIDTH_MS = 60 * 60 * 1000 +const MIN_WIDTH_MS = 60 * 1000 + +function nextWindowWidth(widthMs: number, elapsedMs: number, targetMs: number): number { + const step = Math.min(MAX_STEP, Math.max(1 / MAX_STEP, targetMs / Math.max(elapsedMs, 1))) + return Math.max(MIN_WIDTH_MS, widthMs * step) +} + +// A window's cost is expected to follow its width. It does not when the database answers it +// from an index that ignores the time bounds (another filter's own index, or no index leading +// with the sort column): every window then costs a pass over the whole history, and scanning +// would multiply the load it exists to bound. +function tooSlow(): Error { + return new Error( + `Listing the jobs of a single ${MIN_WIDTH_MS / 1000}s window is too slow. Narrow the filters.` + ) +} + +export interface JobWindow { + /** Upper bound, absent on a window that starts at the newest job. */ + before?: string + after: string + limit: number +} + +export interface JobWindowScan { + /** Upper bound of the scan, sent as is on the first window. null starts at the newest job. */ + before: string | null + /** Lower bound of the scan. null ends it at the oldest job, as given by `oldest`. */ + after: string | null + pageSize: number + oldest: () => Promise + fetchWindow: (w: JobWindow) => CancelablePromise + /** `fraction` is the share of the scan's time range listed so far. */ + onWindow: (jobs: Job[], scannedTo: string, fraction: number) => void + targetMs?: number + patienceMs?: number +} + +const SLOW = Symbol('slow') + +// Both bounds of a window are inclusive on the server, whose timestamps have microsecond +// precision. A window therefore ends one microsecond before the previous one started: sharing +// the bound would list a job created exactly on it twice, and count it twice toward the page. +function justBefore(ms: number): string { + return new Date(ms - 1).toISOString().replace('Z', '999Z') +} + +/** + * Lists the completed jobs of a time range newest first, one bounded window after the other, + * which is the same page a single request over the whole range returns, except that no request + * has to scan more history than the server allows one to. Resolves to whether the range was + * scanned to its end, false meaning the page filled up first. + */ +export function scanJobWindows(scan: JobWindowScan): CancelablePromise { + const targetMs = scan.targetMs ?? TARGET_MS + const patienceMs = scan.patienceMs ?? PATIENCE_MS + return new CancelablePromise((resolve, reject, onCancel) => { + let current: CancelablePromise | undefined + onCancel(() => current?.cancel()) + + async function fetchWithPatience(w: JobWindow): Promise { + const request = scan.fetchWindow(w) + current = request + let timer: ReturnType | undefined + const slow = new Promise((r) => (timer = setTimeout(() => r(SLOW), patienceMs))) + try { + const res = await Promise.race([request, slow]) + if (res === SLOW) { + request.catch(() => {}) + request.cancel() + } + return res + } finally { + clearTimeout(timer) + } + } + + async function run(): Promise { + const floorTs = scan.after ?? (await scan.oldest()) + if (!floorTs) return true + const floor = new Date(floorTs).getTime() + let upper = scan.before ? new Date(scan.before).getTime() : Date.now() + const start = upper + let first = true + let width = INITIAL_WIDTH_MS + let listed = 0 + // The first window is listed even when the scan starts at its floor: the bounds are + // inclusive, and jobs sharing the cursor's timestamp may still be missing from the page. + while (first || upper > floor) { + if (onCancel.isCancelled) return false + const lower = Math.min(upper, Math.max(floor, upper - width)) + const w: JobWindow = { + before: first ? (scan.before ?? undefined) : justBefore(upper), + after: new Date(lower).toISOString(), + limit: scan.pageSize - listed + } + const startedAt = Date.now() + let res: Job[] | typeof SLOW + try { + res = await fetchWithPatience(w) + } catch (e) { + // A window the server refused to finish is retried narrower like a slow one. + if (onCancel.isCancelled || width <= MIN_WIDTH_MS) throw e + res = SLOW + } + if (res === SLOW) { + if (width <= MIN_WIDTH_MS) throw tooSlow() + // Well below what the elapsed time suggests: each dropped window is one more + // statement left running on the server, so the retry has to land. + width = Math.max(MIN_WIDTH_MS, width / (MAX_STEP * MAX_STEP)) + continue + } + const completed = res.filter((j) => j.type === 'CompletedJob').length + scan.onWindow(res, w.after, start > floor ? (start - lower) / (start - floor) : 1) + if (completed >= w.limit) return false + listed += completed + upper = lower + first = false + const elapsed = Date.now() - startedAt + if (width <= MIN_WIDTH_MS && elapsed > targetMs && upper > floor) throw tooSlow() + width = nextWindowWidth(width, elapsed, targetMs) + } + return true + } + + run().then(resolve, reject) + }) +} diff --git a/frontend/src/lib/components/runs/useJobsLoader.svelte.ts b/frontend/src/lib/components/runs/useJobsLoader.svelte.ts index 828f060972..8055278c77 100644 --- a/frontend/src/lib/components/runs/useJobsLoader.svelte.ts +++ b/frontend/src/lib/components/runs/useJobsLoader.svelte.ts @@ -17,10 +17,27 @@ import { subtractDaysFromDateString } from '$lib/utils' import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils' import type { Timeframe } from './timeframes' import { allowWildcards as _allowWildcards, type RunsFilterInstance } from './runsFilter' +import { scanJobWindows } from './jobsWindowScan' // windmill_common::utils::MAX_PER_PAGE: the server silently caps per_page at this value const MAX_PER_PAGE = 10000 +// How long a listing may run before a scan by time windows starts alongside it. A filter +// matching fewer jobs than the page holds walks the whole history to find that out, which the +// server stops after its statement timeout, with nothing to show for it. +const WINDOW_SCAN_AFTER_MS = 4000 + +const EPOCH = new Date(0).toISOString() + +interface JobsQuery { + completedBefore?: string | null + completedAfter?: string | null + createdBefore?: string + createdAfter?: string + createdAfterQueue?: string + perPage?: number +} + export function computeJobKinds(jobKindsCat: string | null): string { if (jobKindsCat == 'all') { return '' @@ -103,6 +120,13 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { let loadingExtra = $state(false) let lastFetchWentToEnd = $state(true) let batchProgress = $state<{ loaded: number; total: number } | null>(null) + let scanProgress = $state<{ scannedTo: string; fraction: number } | null>(null) + let activeScan: CancelablePromise | undefined + // The page being listed, single request or scan. Only the filter-change load is otherwise + // tracked for cancellation, and a page left running writes into whatever listing replaces its own. + let activePage: CancelablePromise | undefined + // Queue-only views list no completed job, and concurrency-key views go through another endpoint. + let canScanWindows = $derived(!isQueueOnly && (concurrencyKey == null || concurrencyKey === '')) let completedJobs: CompletedJob[] | undefined = $state() let externalJobs: Job[] | undefined = $state() @@ -119,6 +143,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { function onParamChanges() { resetJobs() slowStreamIntervalId = setInterval(() => { + if (scanProgress) return sendUserToast( 'Loading is taking a long time...', 'warning', @@ -128,7 +153,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { ) }, 15000) let promise = loadJobsIntern(true) - if (perPage > 25) { + if (perPage > 25 && !canScanWindows) { promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => { const noStartDate = timeframe?.computeMinMax().minTs == null sendUserToast( @@ -159,6 +184,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { paramChangePromise?.cancel() resetJobs() slowStreamIntervalId = setInterval(() => { + if (scanProgress) return sendUserToast( 'Loading is taking a long time...', 'warning', @@ -196,8 +222,13 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { loading = true loadingExtra = true const batchSize = Math.min(perPage, 1000) - await loadExtraJobsBatch(batchSize) - loadingExtra = false + try { + await loadExtraJobsBatch(batchSize) + } catch (e) { + if (!(e instanceof CancelError)) throw e + } finally { + loadingExtra = false + } } // Mirrors when list_completed_jobs_query sorts by completed_at. A created_at cursor does not @@ -231,37 +262,201 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { const cursor = stepOver ? new Date(new Date(cursorTs).getTime() - 1).toISOString() : cursorTs const pageSize = stepOver ? batchSize : Math.min(batchSize + tied, MAX_PER_PAGE) return CancelablePromiseUtils.map( - byCompletedAt - ? fetchJobs(cursor, minTs, undefined, undefined, pageSize) - : fetchJobs(null, minTs, undefined, cursor, pageSize), - (olderJobs) => { - jobs = updateWithNewJobs(olderJobs ?? [], jobs ?? []) - if (extendedJobs) { - extendedJobs.jobs = jobs ?? [] - extendedJobs = extendedJobs + fetchPage( + { + byCompletedAt, + before: cursor, + after: minTs, + pageSize, + query: byCompletedAt + ? { completedBefore: cursor, completedAfter: minTs, perPage: pageSize } + : { completedAfter: minTs, createdBefore: cursor, perPage: pageSize } + }, + (olderJobs) => { + jobs = updateWithNewJobs(olderJobs, jobs ?? []) + if (extendedJobs) { + extendedJobs.jobs = jobs ?? [] + extendedJobs = extendedJobs + } + computeCompletedJobs() } - computeCompletedJobs() - lastFetchWentToEnd = (olderJobs?.length ?? 0) < pageSize + ), + (wentToEnd) => { + lastFetchWentToEnd = wentToEnd loading = false } ) } - function fetchJobs( - completedBefore: string | null, - completedAfter: string | null, - createdAfterQueue: string | undefined, - createdBefore?: string, - perPageOverride?: number - ): CancelablePromise { + // Lists one page of jobs older than `before`, handing them to `onJobs` as they arrive, and + // resolves to whether the listing reached the end of the range. `query` is the whole page in + // one request. When that is slow, the page is also listed by time windows, and whichever + // completes it first wins: the server runs an abandoned request to its timeout regardless, so + // dropping it for the scan would only throw away an answer already being paid for. + function fetchPage( + page: { + byCompletedAt: boolean + before: string | null + after: string | null + pageSize: number + withQueue?: boolean + query: JobsQuery + }, + onJobs: (jobs: Job[]) => void + ): CancelablePromise { + if (!canScanWindows || _args.skip) { + return CancelablePromiseUtils.map(fetchJobs(page.query), (res) => { + onJobs(res) + return res.length < page.pageSize + }) + } + const fetched = new CancelablePromise((resolve, reject, onCancel) => { + const single = listJobs(page.query) + let scan: CancelablePromise | undefined + onCancel(() => { + clearTimeout(timer) + single.cancel() + scan?.cancel() + }) + // Either side failing leaves the page to the other: it is lost only once both have. + let scanError: any + let singleError: any + const giveUp = (e: any, wentToEnd: boolean) => { + sendUserToast(`Could not load jobs: ${e.body ?? e.message}`, true) + console.error(e) + onJobs([]) + resolve(wentToEnd) + } + const timer = setTimeout(() => { + scan = scanWindows(page, onJobs) + scan.then( + (wentToEnd) => { + resolve(wentToEnd) + single.cancel() + }, + (e) => { + if (e instanceof CancelError) return reject(e) + scanError = e + if (singleError) giveUp(scanError, false) + } + ) + }, WINDOW_SCAN_AFTER_MS) + single.then( + (res) => { + clearTimeout(timer) + onJobs(res) + resolve(res.length < page.pageSize) + scan?.cancel() + }, + (e: any) => { + if (e instanceof CancelError) return + if (!scan) { + clearTimeout(timer) + giveUp(e, true) + return + } + // Once the scan runs, the single request failing is the expected server timeout. + singleError = e + if (scanError) giveUp(scanError, false) + } + ) + }) + activePage?.cancel() + activePage = fetched + const done = () => { + if (activePage === fetched) activePage = undefined + } + fetched.then(done, done) + return fetched + } + + function scanWindows( + page: { + byCompletedAt: boolean + before: string | null + after: string | null + pageSize: number + withQueue?: boolean + }, + onJobs: (jobs: Job[]) => void + ): CancelablePromise { + const workspace = currentWorkspace + const scan = scanJobWindows({ + before: page.before, + after: page.after, + pageSize: page.pageSize, + oldest: async () => + (await JobService.getOldestJob({ workspace, allWorkspaces: allWorkspaces || undefined })) + .created_at, + fetchWindow: (w) => { + // Only the first window of a listing that started at the newest job carries the queue. + // A created_before excludes it from the others, and bounds no completed job out of a + // completed_at window since a job is created before it completes. + const withQueue = page.withQueue && w.before == page.before + return listJobs( + page.byCompletedAt + ? { + completedBefore: w.before, + completedAfter: w.after, + createdBefore: withQueue ? undefined : w.before, + createdAfterQueue: page.after ?? undefined, + perPage: w.limit + } + : { + createdBefore: w.before, + createdAfter: w.after, + // created_after would bound the queue too, hiding older jobs still in it + createdAfterQueue: withQueue ? EPOCH : undefined, + perPage: w.limit + } + ) + }, + onWindow: (res, scannedTo, fraction) => { + if (activeScan !== scan) return + scanProgress = { scannedTo, fraction } + onJobs(res) + } + }) + activeScan?.cancel() + activeScan = scan + scanProgress = { scannedTo: page.before ?? new Date().toISOString(), fraction: 0 } + onJobs([]) + const done = () => { + if (activeScan === scan) { + activeScan = undefined + scanProgress = null + } + } + scan.then(done, done) + return scan + } + + function fetchJobs(q: JobsQuery): CancelablePromise { if (_args.skip) return CancelablePromiseUtils.pure([]) + return CancelablePromiseUtils.catchErr(listJobs(q), (e) => { + if (e instanceof CancelError) return CancelablePromiseUtils.err(e) + sendUserToast(`Could not load jobs: ${e.body ?? e.message}`, true) + console.error(e) + return CancelablePromiseUtils.pure([]) + }) + } + + function listJobs({ + completedBefore, + completedAfter, + createdBefore, + createdAfter, + createdAfterQueue, + perPage: perPageOverride + }: JobsQuery): CancelablePromise { loadingFetch = true let scriptPathStart = folder == null || folder === '' ? undefined : `f/${folder}/` let scriptPathExact = path == null || path === '' ? undefined : path let isCompletedOnly = success == 'success' || success == 'failure' || success == 'canceled' let promise = JobService.listJobs({ workspace: currentWorkspace, - createdBefore: createdBefore ?? undefined, + createdBefore, + createdAfter, completedBefore: isQueueOnly ? undefined : (completedBefore ?? undefined), completedAfter: isQueueOnly ? undefined : (completedAfter ?? undefined), createdBeforeQueue: isQueueOnly ? (completedBefore ?? undefined) : undefined, @@ -314,15 +509,10 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { broadFilter, excludesEntrypointOverride: excludesEntrypointOverride ? true : undefined }) - promise = CancelablePromiseUtils.catchErr(promise, (e) => { - if (e instanceof CancelError) return CancelablePromiseUtils.err(e) - sendUserToast(`Could not load jobs: ${e.body ?? e.message}`, true) - console.error(e) - return CancelablePromiseUtils.pure([]) - }) - CancelablePromiseUtils.pipe(promise, () => { + const settled = () => { loadingFetch = false - }) + } + promise.then(settled, settled) return promise } @@ -387,11 +577,16 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { async function loadJobs(reset: boolean, shouldGetCount?: boolean): Promise { if (reset) resetJobs() - await loadJobsIntern(shouldGetCount) + try { + await loadJobsIntern(shouldGetCount) + } catch (e) { + if (!(e instanceof CancelError)) throw e + } } function stopBatchLoading(): void { paramChangePromise?.cancel() + activePage?.cancel() if (slowStreamIntervalId) { clearInterval(slowStreamIntervalId) slowStreamIntervalId = undefined @@ -402,6 +597,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { } function resetJobs() { + activePage?.cancel() jobs = undefined completedJobs = undefined externalJobs = undefined @@ -438,15 +634,35 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { batchProgress = { loaded: 0, total: perPage } } currentBatchSize = batchSize - let slowBatchToastShown = false - let firstFetchPromise = fetchJobs( - maxTs, - extendedMinTs ?? null, - extendedMinTs, - undefined, - batchSize + // The windowed scan streams a slow listing by itself, whatever the batch size. + let slowBatchToastShown = canScanWindows + let firstJobs: Job[] = [] + let firstFetchPromise = fetchPage( + { + byCompletedAt: sortsByCompletedAt(minTs, maxTs), + before: maxTs, + after: extendedMinTs ?? null, + pageSize: batchSize, + withQueue: true, + query: { + completedBefore: maxTs, + completedAfter: extendedMinTs ?? null, + createdAfterQueue: extendedMinTs, + perPage: batchSize + } + }, + (newJobs) => { + firstJobs = updateWithNewJobs(newJobs, firstJobs) + extendedJobs = { jobs: firstJobs, obscured_jobs: [] } as ExtendedJobs + + // Filter on minTs here and not in the backend + // to get enough data for the concurrency graph + jobs = sortMinDate(minTs, firstJobs) + externalJobs = [] + computeCompletedJobs() + } ) - if (isBatched && batchSize > 1) { + if (isBatched && batchSize > 1 && !slowBatchToastShown) { firstFetchPromise = CancelablePromiseUtils.onTimeout(firstFetchPromise, 4000, () => { if (!slowBatchToastShown) { slowBatchToastShown = true @@ -458,15 +674,8 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { } }) } - let p = CancelablePromiseUtils.map(firstFetchPromise, (newJobs) => { - extendedJobs = { jobs: newJobs, obscured_jobs: [] } as ExtendedJobs - - // Filter on minTs here and not in the backend - // to get enough data for the concurrency graph - jobs = sortMinDate(minTs, newJobs) - externalJobs = [] - computeCompletedJobs() - lastFetchWentToEnd = newJobs.length < batchSize + let p = CancelablePromiseUtils.map(firstFetchPromise, (wentToEnd) => { + lastFetchWentToEnd = wentToEnd loading = false if (isBatched) { batchProgress = { loaded: jobs?.length ?? 0, total: perPage } @@ -608,11 +817,11 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { // unbounded scan of completed jobs, possibly the one that just timed out. Not for // queue-only views: fetchJobs turns this into the queue's created_at bound, hiding // older jobs that suspend or come due. The margin absorbs browser/database skew. - newJobs = await fetchJobs( - maxTs, - minTs ?? completedTs ?? (isQueueOnly ? null : listLoadedAt), - queueTs - ) + newJobs = await fetchJobs({ + completedBefore: maxTs, + completedAfter: minTs ?? completedTs ?? (isQueueOnly ? null : listLoadedAt), + createdAfterQueue: queueTs + }) } else { // Obscured jobs have no ids, so we have to do the full request extendedJobs = await fetchExtendedJobs(concurrencyKey, maxTs, minTs ?? completedTs) @@ -721,6 +930,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { slowStreamIntervalId = undefined } paramChangePromise?.cancel() + activePage?.cancel() }) $effect(() => { Object.keys(filters ?? {}).map((k) => filters?.[k as keyof RunsFilterInstance]) @@ -772,6 +982,9 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { get currentBatchSize() { return currentBatchSize }, + get scanProgress() { + return scanProgress + }, get queue_count() { return queue_count }, From 4fd4f76d0df740cb3eebed1a0625b970484807fc Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 6 Oct 2026 12:40:04 +0200 Subject: [PATCH 055/174] fix: honor custom timeout in setup phases and name the limit hit (#11557) * fix: honor custom timeout in setup phases and name the limit hit Co-Authored-By: Claude Opus 5.5 (1M context) * fix: label a shared-deadline phase timeout as the job's remaining budget Co-Authored-By: Claude Opus 5.5 (1M context) * fix: register dbt deploy jobs so their phase timeout is not called custom Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-worker/src/common.rs | 159 ++++++++++++++++++-- backend/windmill-worker/src/dbt_executor.rs | 10 +- backend/windmill-worker/src/handle_child.rs | 35 ++--- backend/windmill-worker/src/worker.rs | 1 + 4 files changed, 172 insertions(+), 33 deletions(-) diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index cd8923d408..82065e8a37 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -1068,12 +1068,87 @@ pub async fn start_child_process( Ok(child) } +/// Which limit a resolved job timeout comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TimeoutSource { + /// The timeout set on the script or the flow step. + Custom, + /// What is left of a wall clock that the phases of one job spend down together. + RemainingBudget, + /// The instance `job_default_timeout` setting. + InstanceDefault, + /// The instance ceiling (`TIMEOUT`), which no other timeout can exceed. + InstanceMax, +} + +impl TimeoutSource { + /// How to name a limit of `secs` seconds in a message shown to the job's author. + pub fn describe(&self, secs: u64) -> String { + match self { + TimeoutSource::Custom => { + format!("the {secs}s custom timeout set on the script or flow step") + } + TimeoutSource::RemainingBudget => format!( + "the {secs}s left of the job's timeout, which its phases share" + ), + TimeoutSource::InstanceDefault => format!( + "the {secs}s instance default timeout (instance setting 'Default timeout'); set a longer custom timeout on the script or flow step to raise it" + ), + TimeoutSource::InstanceMax => format!( + "the {secs}s instance maximum job duration (TIMEOUT env variable of the worker), which no custom or default timeout can exceed" + ), + } + } +} + +lazy_static! { + static ref RUNNING_JOB_CUSTOM_TIMEOUTS: std::sync::Mutex>> = + std::sync::Mutex::new(HashMap::new()); +} + +/// Makes a running job's custom timeout visible to the phases that are not handed it (dependency +/// resolution, install, build), for as long as the guard lives. +pub struct RunningJobCustomTimeout(Uuid); + +impl RunningJobCustomTimeout { + pub fn register(job_id: Uuid, custom_timeout_secs: Option) -> Self { + if let Ok(mut m) = RUNNING_JOB_CUSTOM_TIMEOUTS.lock() { + m.insert( + job_id, + windmill_common::runnable_settings::none_if_non_positive(custom_timeout_secs), + ); + } + Self(job_id) + } +} + +impl Drop for RunningJobCustomTimeout { + fn drop(&mut self) { + if let Ok(mut m) = RUNNING_JOB_CUSTOM_TIMEOUTS.lock() { + m.remove(&self.0); + } + } +} + +/// `None` when the job is not registered, `Some(None)` when it runs without a custom timeout. +fn registered_custom_timeout(job_id: Uuid) -> Option> { + RUNNING_JOB_CUSTOM_TIMEOUTS + .lock() + .ok() + .and_then(|m| m.get(&job_id).copied()) +} + +/// Resolves the time limit of one phase of a job and where that limit comes from. +/// +/// A phase called without a custom timeout runs under the instance default. A job's custom +/// timeout may only lengthen such a phase, never shorten it: a short run timeout must not kill +/// a dependency install that the instance default allows. pub async fn resolve_job_timeout( _conn: &Connection, _w_id: &str, - _job_id: Uuid, + job_id: Uuid, custom_timeout_secs: Option, -) -> (Duration, Option, bool) { +) -> (Duration, Option, TimeoutSource) { let mut warn_msg: Option = None; #[cfg(feature = "cloud")] let cloud_premium_workspace = @@ -1091,6 +1166,7 @@ pub async fn resolve_job_timeout( let global_max_timeout_duration = Duration::from_secs(max_job_duration_secs(cloud_premium_workspace)); + let max_secs = global_max_timeout_duration.as_secs(); // A `custom_timeout_secs <= 0` is not a 0-second limit but "unset": fall through to the // default/global-max timeout instead of killing the job immediately. @@ -1098,34 +1174,95 @@ pub async fn resolve_job_timeout( Some(timeout_secs) if Duration::from_secs(timeout_secs as u64) < global_max_timeout_duration => { - (Duration::from_secs(timeout_secs as u64), warn_msg, true) + // A phase handed anything but the job's own custom timeout was handed the rest of a + // shared wall clock, whatever limit that clock was started from. + let source = + if registered_custom_timeout(job_id).is_none_or(|t| t == Some(timeout_secs)) { + TimeoutSource::Custom + } else { + TimeoutSource::RemainingBudget + }; + (Duration::from_secs(timeout_secs as u64), warn_msg, source) } Some(timeout_secs) => { - warn_msg = Some(format!("WARNING: Custom job timeout of {timeout_secs} seconds was greater than the maximum timeout. It will be ignored and the max timeout will be used instead")); + warn_msg = Some(format!("WARNING: Custom job timeout of {timeout_secs} seconds was greater than the instance maximum job duration of {max_secs} seconds (TIMEOUT env variable of the worker). It will be ignored and the max timeout will be used instead")); tracing::warn!(warn_msg); - (global_max_timeout_duration, warn_msg, false) + ( + global_max_timeout_duration, + warn_msg, + TimeoutSource::InstanceMax, + ) } None => { // fallback to default timeout or max if not set - let default_timeout = match JOB_DEFAULT_TIMEOUT.read().await.clone() { - None => global_max_timeout_duration, + let (default_timeout, source) = match JOB_DEFAULT_TIMEOUT.read().await.clone() { + None => (global_max_timeout_duration, TimeoutSource::InstanceMax), Some(default_timeout_secs) if Duration::from_secs(default_timeout_secs as u64) < global_max_timeout_duration => { - Duration::from_secs(default_timeout_secs as u64) + ( + Duration::from_secs(default_timeout_secs as u64), + TimeoutSource::InstanceDefault, + ) } Some(default_timeout_secs) => { - warn_msg = Some(format!("WARNING: Default job timeout of {default_timeout_secs} seconds was greater than the maximum timeout. It will be ignored and the global max timeout will be used instead")); + warn_msg = Some(format!("WARNING: Default job timeout of {default_timeout_secs} seconds was greater than the instance maximum job duration of {max_secs} seconds (TIMEOUT env variable of the worker). It will be ignored and the max timeout will be used instead")); tracing::warn!(warn_msg); - global_max_timeout_duration + (global_max_timeout_duration, TimeoutSource::InstanceMax) } }; - (default_timeout, warn_msg, false) + let job_custom_timeout = registered_custom_timeout(job_id) + .flatten() + .map(|secs| Duration::from_secs(secs as u64)); + let (timeout, source) = lengthen_with_job_custom_timeout( + default_timeout, + source, + job_custom_timeout, + global_max_timeout_duration, + ); + (timeout, warn_msg, source) } } } +fn lengthen_with_job_custom_timeout( + phase_timeout: Duration, + phase_source: TimeoutSource, + job_custom_timeout: Option, + max_timeout: Duration, +) -> (Duration, TimeoutSource) { + match job_custom_timeout { + Some(custom) if custom > phase_timeout && custom < max_timeout => { + (custom, TimeoutSource::Custom) + } + Some(custom) if custom > phase_timeout => (max_timeout, TimeoutSource::InstanceMax), + _ => (phase_timeout, phase_source), + } +} + +#[cfg(test)] +mod job_timeout_tests { + use super::{lengthen_with_job_custom_timeout, TimeoutSource}; + use std::time::Duration; + + // A phase that is not handed the job's custom timeout (dependency install, build) runs under + // the instance default. The custom timeout may extend it up to the instance max, but a + // shorter one must leave it alone. + #[test] + fn job_custom_timeout_only_lengthens_a_setup_phase() { + let s = Duration::from_secs; + let default = (s(120), TimeoutSource::InstanceDefault); + let resolve = |custom: Option| { + lengthen_with_job_custom_timeout(default.0, default.1, custom.map(s), s(600)) + }; + assert_eq!(resolve(None), default); + assert_eq!(resolve(Some(240)), (s(240), TimeoutSource::Custom)); + assert_eq!(resolve(Some(30)), default); + assert_eq!(resolve(Some(900)), (s(600), TimeoutSource::InstanceMax)); + } +} + /// Compute the nsjail timeout (in seconds) with a 15s buffer so handle_child fires first. pub async fn resolve_nsjail_timeout( conn: &Connection, diff --git a/backend/windmill-worker/src/dbt_executor.rs b/backend/windmill-worker/src/dbt_executor.rs index 8119897d6d..90db1b9795 100644 --- a/backend/windmill-worker/src/dbt_executor.rs +++ b/backend/windmill-worker/src/dbt_executor.rs @@ -779,6 +779,7 @@ pub(crate) async fn dbt_dep( // A dependency job carries no per-job timeout of its own, but its phases // still share one wall clock rather than each getting the instance-wide // one. + let _custom_timeout = crate::common::RunningJobCustomTimeout::register(*job_id, None); let deadline = JobDeadline::start(&conn, w_id, *job_id, None).await; let mut ctx = JobCtx { mem_peak, canceled_by, occupancy_metrics, worker_name, deadline }; let prepared = prepare_project( @@ -3560,11 +3561,10 @@ async fn attach_column_index( .iter() .map(|n| n.unique_id.as_str()) .collect(); - let index = - crate::dbt_column_index::collect( - p, descriptor, inv, command, ctx, job_id, w_id, conn, &kept, - ) - .await?; + let index = crate::dbt_column_index::collect( + p, descriptor, inv, command, ctx, job_id, w_id, conn, &kept, + ) + .await?; drop(kept); let Some(index) = index else { return Ok(()); diff --git a/backend/windmill-worker/src/handle_child.rs b/backend/windmill-worker/src/handle_child.rs index 3c9be1f4c5..22a2bf4e8b 100644 --- a/backend/windmill-worker/src/handle_child.rs +++ b/backend/windmill-worker/src/handle_child.rs @@ -49,7 +49,7 @@ use futures::{ stream, StreamExt, }; -use crate::common::{resolve_job_timeout, OccupancyMetrics, StreamNotifier}; +use crate::common::{resolve_job_timeout, OccupancyMetrics, StreamNotifier, TimeoutSource}; use crate::job_logger::{append_job_logs, append_result_stream, append_with_limit, strip_nul}; use crate::job_logger_oss::process_streaming_log_lines; use crate::worker_utils::{ping_job_status, update_worker_ping_from_job}; @@ -181,7 +181,7 @@ pub async fn handle_child( enum KillReason { TooManyLogs, - Timeout { is_job_specific: bool }, + Timeout { phase: String, source: TimeoutSource, secs: u64 }, Cancelled(Option), AlreadyCompleted, } @@ -190,11 +190,9 @@ pub async fn handle_child( fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { KillReason::TooManyLogs => f.write_str("too many logs (max size: 2MB)"), - KillReason::Timeout { is_job_specific } => f.write_str(if *is_job_specific { - "timeout after exceeding job-specific duration limit" - } else { - "timeout after exceeding instance-wide job duration limit" - }), + KillReason::Timeout { phase, source, secs } => { + write!(f, "timeout: `{phase}` exceeded {}", source.describe(*secs)) + } KillReason::Cancelled(canceled_by) => { let mut reason = "cancelled".to_string(); if let Some(canceled_by) = canceled_by { @@ -212,7 +210,7 @@ pub async fn handle_child( } } - let (timeout_duration, timeout_warn_msg, is_job_specific) = + let (timeout_duration, timeout_warn_msg, timeout_source) = resolve_job_timeout(&conn, w_id, job_id, custom_timeout).await; if let Some(msg) = timeout_warn_msg { append_logs(&job_id, w_id, msg.as_str(), conn).await; @@ -224,7 +222,11 @@ pub async fn handle_child( biased; result = Box::into_pin(child.wait()) => return result.map(Ok), Ok(()) = too_many_logs.changed() => KillReason::TooManyLogs, - _ = sleep(timeout_duration) => KillReason::Timeout { is_job_specific }, + _ = sleep(timeout_duration) => KillReason::Timeout { + phase: child_name.to_string(), + source: timeout_source, + secs: timeout_duration.as_secs(), + }, ex = update_job, if job_id != Uuid::nil() => match ex { UpdateJobPollingExit::Done(canceled_by) => KillReason::Cancelled(canceled_by), UpdateJobPollingExit::AlreadyCompleted => KillReason::AlreadyCompleted, @@ -233,17 +235,16 @@ pub async fn handle_child( tx.send(()).expect("rx should never be dropped"); drop(tx); + let timeout_reason = format!( + "`{child_name}` exceeded {}", + timeout_source.describe(timeout_duration.as_secs()) + ); let set_reason = async { if matches!(kill_reason, KillReason::Timeout { .. }) { match conn { Connection::Sql(db) => { - if let Err(err) = set_job_cancelled_query( - job_id, - db, - "timeout", - &format!("duration > {}", timeout_duration.as_secs()), - ) - .await + if let Err(err) = + set_job_cancelled_query(job_id, db, "timeout", &timeout_reason).await { tracing::error!(%job_id, %err, "error setting cancelation reason for job {job_id}: {err}"); } @@ -255,7 +256,7 @@ pub async fn handle_child( None, &JobCancelled { canceled_by: "timeout".to_string(), - reason: format!("duration > {}", timeout_duration.as_secs()), + reason: timeout_reason.clone(), }, ) .await diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b88d3341d3..80ca37acb8 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -5401,6 +5401,7 @@ async fn handle_code_execution_job( precomputed_agent_info: Option, has_stream: &mut bool, ) -> error::Result> { + let _custom_timeout = crate::common::RunningJobCustomTimeout::register(job.id, job.timeout); let script_hash = || { job.runnable_id .ok_or_else(|| Error::internal_err("expected script hash")) From 8c74f75de3fa62b07186ca4f56dae4a7e4758034 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 6 Oct 2026 14:18:13 +0200 Subject: [PATCH 056/174] fix: simplify the run page AI card to a prompt and a chat button (#11561) * fix: drop the add a prompt for AI button from the run page * fix: open the run page in the session preview from a plain chat button * fix: rename additional prompt for AI to AI prompt * fix: put the run page chat button beside advanced when no AI prompt --- frontend/src/lib/components/RunForm.svelte | 5 +- .../components/copilot/AIFormAssistant.svelte | 91 ++++++++--------- .../components/copilot/AIFormSettings.svelte | 2 +- .../lib/components/details/FlowDetail.svelte | 25 +++-- .../components/details/ScriptDetail.svelte | 25 +++-- .../flows/content/FlowSettings.svelte | 2 +- .../components/sessions/ItemViewerView.svelte | 97 ++++++++++--------- 7 files changed, 127 insertions(+), 120 deletions(-) diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 2ae75f3c47..7be09785b1 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -152,8 +152,8 @@ * form is embedded in a page that is not the runnable's own — an AI session preview * tab — since there the fragment would land on an unrelated URL. */ syncArgsToUrl?: boolean - /** Controls beside the Run button of a form that cannot schedule, in the row a - * schedulable one gives its Advanced options. */ + /** Controls beside the Run button: left of Advanced on a schedulable form, in + * Advanced's place on one that cannot schedule. */ actions?: import('svelte').Snippet } @@ -405,6 +405,7 @@ {/snippet}
+ {@render actions?.()}
{@render unparseableError()} {#if overrideTag} diff --git a/frontend/src/lib/components/copilot/AIFormAssistant.svelte b/frontend/src/lib/components/copilot/AIFormAssistant.svelte index 72ab57cab6..b4526f1153 100644 --- a/frontend/src/lib/components/copilot/AIFormAssistant.svelte +++ b/frontend/src/lib/components/copilot/AIFormAssistant.svelte @@ -8,10 +8,13 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { copilotInfo } from '$lib/aiStore' import { logFeatureUsage } from '$lib/utils/featureUsage' + import { pageHref } from '$lib/components/sessions/previewPaths' interface Props { /** Unset for a viewer who cannot edit the item: the card then offers no way to the editor. */ onEditInstructions: (() => void) | undefined + /** Empty renders the AI button alone, sized for the run form's action row, which + * is where the host places it; a prompt makes this a card of its own. */ instructions: string runnableType: 'script' | 'flow' path: string | undefined @@ -37,72 +40,52 @@ aiChatManager.askAi(`Analyze the ${runnableType} form on this page and fill the inputs for me`) } - // A session cannot reach this page's form (the preview is a separate editor, - // and form filling drives the DOM through NAVIGATOR mode), so the hand-off - // asks it to run the item instead. Naming the DEPLOYED version matters: the - // test_run_* tools prefer drafts, which is not what this page runs. + // The session's preview opens on this same deployed page (`/get/`), not the item's + // editor: someone on a run page came to run the item, not to change it. const sessionSource = $derived( path ? { - target: { kind: runnableType, path } as const, + page: () => pageHref(`/${runnableType}s/get/${path}`), workspaceId: $operatingWorkspace ?? undefined, - beforeOpen: logAsked, - seedPrompt: - `Run the deployed ${runnableType} \`${path}\` for me. Pick sensible inputs, ` + - `tell me what you chose, then run it.` + - (instructions ? `\n\nHow to choose the inputs:\n${instructions}` : '') + beforeOpen: logAsked } : undefined ) -{#if !$copilotInfo.workspaceDisabled} +{#snippet aiButton(unifiedSize: 'sm' | 'md')} + + + {#snippet fallback()} + + {/snippet} + +{/snippet} + +{#if $copilotInfo.workspaceDisabled} + +{:else if instructions}
- {#if instructions} - - {:else if onEditInstructions} - - {:else} - - {/if} - - (expanded = !expanded)} > - {#snippet fallback()} - - {/snippet} - + AI prompt + + {@render aiButton('sm')}
- {#if instructions && expanded} + {#if expanded}
{/if}
+{:else} + {@render aiButton('md')} {/if} diff --git a/frontend/src/lib/components/copilot/AIFormSettings.svelte b/frontend/src/lib/components/copilot/AIFormSettings.svelte index 2094dda9f5..4bae2a825a 100644 --- a/frontend/src/lib/components/copilot/AIFormSettings.svelte +++ b/frontend/src/lib/components/copilot/AIFormSettings.svelte @@ -13,7 +13,7 @@ {#if !$copilotInfo.workspaceDisabled}
diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 89708ea0ca..078ab20a47 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -166,6 +166,7 @@ // asynchronously, and a value read after `getScriptByHash` resolves can land before that // lookup does. Assigned once, that leaves the page permanently read-only — `canWrite` // refuses an unknown user — with nothing to recompute it. A hub script is never writable. + const promptForAi = $derived((script?.schema?.prompt_for_ai as string | undefined) ?? '') const can_write = $derived( !isHubScript && !!script && @@ -1073,15 +1074,20 @@
{/if} - {#if Object.keys(script?.schema?.properties ?? {}).length > 0} - onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`) - : undefined} - runnableType="script" - path={script?.path} - /> + {#snippet aiAssistant()} + {#if Object.keys(script?.schema?.properties ?? {}).length > 0} + onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`) + : undefined} + runnableType="script" + path={script?.path} + /> + {/if} + {/snippet} + {#if promptForAi} + {@render aiAssistant()} {/if}
diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index 079647112c..0ae94b208e 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -93,7 +93,7 @@ let activeAdvancedOptions = $derived([ { - name: 'Additional prompt for AI', + name: 'AI prompt', active: !!flowStore.val.schema?.prompt_for_ai }, { diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index b805f1b91a..f02b215350 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -7,7 +7,7 @@ * is caught here and turned into a move inside the preview panel, so nothing takes * the browser out of the session. */ - import { untrack } from 'svelte' + import { setContext, untrack } from 'svelte' import { base } from '$lib/base' import ScriptDetail from '$lib/components/details/ScriptDetail.svelte' import FlowDetail from '$lib/components/details/FlowDetail.svelte' @@ -48,6 +48,13 @@ active?: boolean } = $props() + // Mounted in the preview panel, outside the chat's own subtree, as PageItemEditorView is: + // the detail pages' AI entry points would otherwise offer a session from inside one. + setContext( + 'aiChatManager', + untrack(() => runtime.manager) + ) + // This reads the deployed version over the API rather than from the editor cell the // chat mutates, so it is the one preview kind that does not self-sync (the invariant // `previewReload.ts` states for editors). A deploy from the editor topbar beside it @@ -179,50 +186,50 @@ - {#key loadKey} - {@const mountedKey = loadKey} - {@const setLoadState = (state: 'loaded' | 'not_found') => onLoadState(mountedKey, state)} - {#if notDeployed} - -
-
Not deployed yet
-
- Nothing is deployed at {path}, so there is no deployed - version to view. Deploy it from the editor to see it here. -
- +
+
Not deployed yet
+
+ Nothing is deployed at {path}, so there is no deployed + version to view. Deploy it from the editor to see it here.
- {:else if kind === 'script'} - - {:else if kind === 'flow'} - - {:else} - onNavigate(`/apps_raw/edit/${path}`)} - onLoadState={setLoadState} - syncHashToUrl={false} - /> - {/if} - {/key} + +
+ {:else if kind === 'script'} + + {:else if kind === 'flow'} + + {:else} + onNavigate(`/apps_raw/edit/${path}`)} + onLoadState={setLoadState} + syncHashToUrl={false} + /> + {/if} +{/key} From b371af31c96e6890fe9223233d2c6e53ddea67d4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 6 Oct 2026 14:22:12 +0200 Subject: [PATCH 057/174] feat: confine jobs:read to the runnable paths it names (#11550) * feat: confine jobs:read to the runnable paths it names Co-Authored-By: Claude Opus 5.5 (1M context) * fix: filter job lists by read scopes alone and keep a job's own flow run readable Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/tests/fixtures/jobs_read_auth.sql | 7 + backend/tests/jobs_read_auth.rs | 50 ++++ backend/windmill-api-auth/src/auth.rs | 2 +- backend/windmill-api-auth/src/scopes.rs | 340 ++++++++++++++++++---- backend/windmill-api-jobs/src/query.rs | 28 ++ backend/windmill-api/src/agent_runs.rs | 2 +- backend/windmill-api/src/jobs.rs | 131 ++++++--- backend/windmill-api/src/token.rs | 2 +- 8 files changed, 471 insertions(+), 91 deletions(-) diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql index 980d68b711..e32b777a74 100644 --- a/backend/tests/fixtures/jobs_read_auth.sql +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -29,6 +29,13 @@ INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, sc ARRAY['jobs:run:flows:f/shared/flow1'] ); +-- A path-scoped read token for test-user-2: it reads the runs of `f/shared/flow1` only. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES ( + encode(sha256('READ_SCOPED_TOKEN'::bytea), 'hex'), 'READ_SCOP', 'READ_SCOPED_TOKEN', + 'test2@windmill.dev', 'flow history token', false, + ARRAY['jobs:read:f/shared/flow1'] +); + -- Same, scoped to a script. The two jobs below both run through a `singlestepflow` -- wrapper (native retry / scheduled runs produce these) — one wrapping a script, one -- wrapping a flow — so the confinement has to project each onto the runnable it wraps diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index fd2969a18f..6a101200a1 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -564,6 +564,56 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul "run-scoped token must not enumerate jobs (got {status}): {body}" ); + // ---- PATH-SCOPED READ TOKEN: READ_SCOPED_TOKEN is test-user-2's + // `jobs:read:f/shared/flow1`. It reads that flow's runs and the steps beneath + // them, and nothing else its owner created. + for (path, expected) in [ + ( + format!("completed/get_result/{FLOW_JOB}"), + r#""flow": "done""#, + ), + ( + format!("completed/get_result/{STEP_JOB}"), + "STEP_RESULT_INHERITED", + ), + ] { + let (status, body) = get(&base, &path, Some("READ_SCOPED_TOKEN")).await; + assert!( + status.is_success() && body.contains(expected), + "read-scoped token must read a run of its flow ({path}, got {status}): {body}" + ); + } + for path in [ + format!("completed/get_result/{VICTIM}"), + format!("get/{VICTIM}"), + format!("flow/approval_info/{VICTIM}"), + ] { + let (status, body) = get(&base, &path, Some("READ_SCOPED_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "read-scoped token must not read a job outside its paths ({path}, got {status}): {body}" + ); + } + // The lists answer with the top-level runs at its paths only, where the same user's + // unscoped token sees the other runs too. + for list in ["list", "completed/list"] { + let (status, body) = get(&authed_base, list, Some("READ_SCOPED_TOKEN")).await; + assert!(status.is_success(), "{list} (got {status}): {body}"); + let jobs: Vec = serde_json::from_str(&body)?; + let mut ids: Vec<&str> = jobs.iter().filter_map(|j| j["id"].as_str()).collect(); + ids.sort(); + assert_eq!(ids, [WRAPPED_FLOW_JOB, FLOW_JOB], "{list}: {body}"); + } + let (_, body) = get(&authed_base, "list", Some("SECRET_TOKEN_2")).await; + assert!(body.contains(VICTIM), "unscoped list: {body}"); + let (status, body) = get(&authed_base, "completed/count", Some("READ_SCOPED_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "read-scoped token must not count the workspace's jobs (got {status}): {body}" + ); + // ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token // may cancel a job it launched (created_by == viewer), but `cancel_job_api` // denies (NotFound) a job created by someone else, even one the (admin) diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index ced8385ef5..720111e89d 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -107,7 +107,7 @@ pub struct ExpiringAuthCache { /// Whether `target` belongs to the flow run of the job `job_id`: the job itself, one of its /// ancestors, or a job whose parent or root is one of them (a sibling step, a loop or branch /// iteration, any job pushed as a child of one of them). A lookup that fails reads as no. -async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool { +pub async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool { if job_id == target { return true; } diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 4fa85dfa3a..dce6a0ff72 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -302,7 +302,7 @@ fn extract_domain_from_route( /// Every entry is keyed by a job id and confines an authenticated caller to its own /// runnable — through `require_job_read_access`, through its own `jobs:run:flows:` /// check, or, where an approval token or resume secret bypasses that gate, through a -/// direct `require_job_within_run_scope`. The one exception is +/// direct `require_job_within_read_scope`. The one exception is /// `jobs_u/get_root_job_id/`, which has no check at all but discloses only flow lineage, /// to anyone, authenticated or not. Workspace-wide enumeration (`jobs/list`, counts, /// exports) and credential minting (`job_view_token`) are deliberately absent — those are @@ -519,23 +519,163 @@ fn resource_metadata_route_allowed(suffix: &str) -> bool { || suffix.starts_with("resources/type/") } -/// The `jobs:run` scopes a token's job reads are confined to, or `None` when they are -/// not confined to particular runnables. +/// The by-id and list routes a path-scoped `jobs:read:` reaches, as workspaced +/// route suffixes. Every other read in the domain is refused to it: counts, exports, +/// uuid listings, queue positions and dispatch edges aggregate over the workspace, and +/// the view-token and resume-url routes mint a credential (`approval_info` withholds +/// its view token from a confined caller for the same reason). +/// +/// Each entry confines the caller in its handler, through `require_job_read_access` or +/// a direct `require_job_within_read_scope`. `jobs/result_by_id/` is absent because it +/// resolves a node up through the enclosing flows, past the job the check admitted. +/// A route added here without that check serves a path-scoped token every job. +const READ_PATH_SCOPED_BY_ID_PATHS: [&'static str; 25] = [ + "jobs_u/get/", + "jobs_u/get_logs/", + "jobs_u/get_completed_logs_tail/", + "jobs_u/get_flow_all_logs/", + "jobs_u/get_flow_all_logs_structured/", + "jobs_u/get_flow_all_results/", + "jobs_u/get_args/", + "jobs_u/get_flow_debug_info/", + "jobs_u/completed/get/", + "jobs_u/completed/get_result/", + "jobs_u/completed/get_result_maybe/", + "jobs_u/completed/get_timing/", + "jobs_u/dispatch_events/", + "jobs_u/getupdate/", + "jobs_u/getupdate_sse/", + "jobs_u/get_log_file/", + "jobs_u/get_flow/", + "jobs_u/flow/approval_info/", + "jobs/run_progress/", + "jobs/flow/user_states/", + "jobs/completed/get/", + "jobs/completed/get_result/", + "jobs/completed/get_result_maybe/", + "jobs/completed/get_timing/", + "jobs/get_otel_traces/", +]; + +/// Matched whole: `jobs/list` is also a prefix of the uuid listings. Their handlers +/// filter in SQL to [`job_list_path_filter`]. +const READ_PATH_SCOPED_LIST_PATHS: [&'static str; 3] = + ["jobs/list", "jobs/completed/list", "jobs/queue/list"]; + +fn is_read_path_scoped_route(suffix: &str) -> bool { + READ_PATH_SCOPED_LIST_PATHS.contains(&suffix) + || READ_PATH_SCOPED_BY_ID_PATHS + .iter() + .any(|p| suffix.starts_with(p)) +} + +/// The paths of a `jobs:read:` scope, or `None` when it spans every job: no +/// path part, or `*` among them. +fn job_read_scope_paths(scope: &ScopeDefinition) -> Option<&[String]> { + scope + .resource + .as_deref() + .filter(|paths| !paths.iter().any(|p| p == "*")) +} + +/// The runnables a token's job reads are confined to: those its `jobs:run` scopes may +/// start, and those its `jobs:read:` scopes name. +pub struct JobReadConfinement { + run: Vec, + read_paths: crate::ScopePathFilter, +} + +impl JobReadConfinement { + /// Whether a job that ran `runnable_path` as `kind` (`scripts`, `flows` or `agents`) + /// is inside the confinement. A read path admits every kind; a run scope only its own. + pub fn admits(&self, kind: &str, runnable_path: &str) -> bool { + if self.read_paths.allows(runnable_path) { + return true; + } + let required = ScopeDefinition::new( + ScopeDomain::Jobs.as_str(), + ScopeAction::Run.as_str(), + Some(kind), + Some(vec![runnable_path.to_string()]), + ); + self.run.iter().any(|scope| scope.includes(&required)) + } + + /// Whether a `jobs:read:` scope is part of the confinement. + pub fn has_read_paths(&self) -> bool { + matches!(&self.read_paths, crate::ScopePathFilter::Restricted { exact, prefix } + if !exact.is_empty() || !prefix.is_empty()) + } +} + +fn push_read_paths(paths: &[String], exact: &mut Vec, prefix: &mut Vec) { + for path in paths { + match path.strip_suffix("/*") { + Some(p) => prefix.push(p.to_string()), + None => exact.push(path.clone()), + } + } +} + +/// The paths a token's job lists are filtered to. Decided by its read grants alone, +/// unlike [`job_read_confinement`]: a `jobs:run` scope grants no enumeration, so even a +/// bare one beside a `jobs:read:` leaves the lists on those paths. Unrestricted +/// for an unscoped token, a `jobs:read` naming no path (or `*`), and `jobs:write`. +pub fn job_list_path_filter(scopes: Option<&[String]>) -> crate::ScopePathFilter { + let mut exact = Vec::new(); + let mut prefix = Vec::new(); + let mut scoped = false; + for scope in scopes + .unwrap_or_default() + .iter() + .filter(|s| !s.starts_with("if_jobs:filter_tags:")) + { + scoped = true; + let Ok(scope) = ScopeDefinition::from_scope_string(scope) else { + continue; + }; + if ScopeDomain::from_str(&scope.domain) != Some(ScopeDomain::Jobs) { + continue; + } + match ScopeAction::from_str(&scope.action) { + Some(ScopeAction::Read) => match job_read_scope_paths(&scope) { + Some(paths) => push_read_paths(paths, &mut exact, &mut prefix), + None => return crate::ScopePathFilter::AllowAll, + }, + Some(ScopeAction::Write) => return crate::ScopePathFilter::AllowAll, + _ => continue, + } + } + if scoped { + crate::ScopePathFilter::Restricted { exact, prefix } + } else { + crate::ScopePathFilter::AllowAll + } +} + +/// What a token's job reads are confined to, or `None` when they are not confined to +/// particular runnables. /// /// A run scope is what the trigger UI mints per script or flow and hands to a webhook /// caller / CI job: it may start the runnables it names and follow those runs, so its -/// by-id job reads must stay within what it can start (enforced by -/// `require_job_read_access`). Both the path (`jobs:run:flows:f/team/etl`) and the -/// kind-only (`jobs:run:scripts`, which legacy `jobs:runscript` tokens carry) forms -/// confine, since `ScopeDefinition::includes` already matches a candidate -/// `jobs:run::` against either. +/// by-id job reads must stay within what it can start. Both the path +/// (`jobs:run:flows:f/team/etl`) and the kind-only (`jobs:run:scripts`, which legacy +/// `jobs:runscript` tokens carry) forms confine, since `ScopeDefinition::includes` +/// already matches a candidate `jobs:run::` against either. +/// +/// A `jobs:read:` scope reads the runs of the runnables at those paths and +/// nothing else. The two kinds of grant add up: a job either admits is readable. /// /// Returns `None` — unconfined — when the token is effectively unscoped, or carries a -/// jobs scope that grants job reads in its own right: `jobs:read`/`jobs:write`, or a -/// bare `jobs:run` (it can start anything, so confining its reads to "what it may run" -/// would restrict nothing). -pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option> { - let mut confinement = Vec::new(); +/// jobs scope that grants every job read in its own right: a `jobs:read` naming no path +/// (or `*`), a bare `jobs:run` (it can start anything, so confining its reads to "what +/// it may run" would restrict nothing), or `jobs:write`, whose paths are ignored on +/// reads as they are on every job write. +pub fn job_read_confinement(scopes: Option<&[String]>) -> Option { + let mut run = Vec::new(); + let mut exact = Vec::new(); + let mut prefix = Vec::new(); + let mut confined = false; for scope in scopes? .iter() .filter(|s| !s.starts_with("if_jobs:filter_tags:")) @@ -548,7 +688,15 @@ pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option { - confinement.push(scope) + confined = true; + run.push(scope) + } + Some(ScopeAction::Read) => { + let Some(paths) = job_read_scope_paths(&scope) else { + return None; + }; + confined = true; + push_read_paths(paths, &mut exact, &mut prefix); } // Grants no reads (see `scope_grants_access`), so it neither confines nor // frees them. @@ -557,23 +705,10 @@ pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option continue, } } - (!confinement.is_empty()).then_some(confinement) -} - -/// Whether a job that ran `runnable_path` as `kind` (`scripts` or `flows`) is inside a -/// [`job_read_run_confinement`] set. -pub fn run_confinement_admits( - confinement: &[ScopeDefinition], - kind: &str, - runnable_path: &str, -) -> bool { - let required = ScopeDefinition::new( - ScopeDomain::Jobs.as_str(), - ScopeAction::Run.as_str(), - Some(kind), - Some(vec![runnable_path.to_string()]), - ); - confinement.iter().any(|scope| scope.includes(&required)) + confined.then_some(JobReadConfinement { + run, + read_paths: crate::ScopePathFilter::Restricted { exact, prefix }, + }) } /// The `jobs:cancel:` scopes a token's cancels are confined to, or `None` when @@ -683,6 +818,17 @@ fn scope_grants_access( })); } + // A path-scoped `jobs:read:` is the one job scope this layer does not treat + // as resource-blind: it reaches only the routes whose handlers confine it to those + // paths, so a job read route is refused to it until it is listed there. + if scope_domain == ScopeDomain::Jobs + && scope_action == ScopeAction::Read + && required_action == ScopeAction::Read + && job_read_scope_paths(scope).is_some() + { + return Ok(route_path.is_some_and(is_read_path_scoped_route)); + } + if !scope_action.includes(&required_action) { return Ok(false); } @@ -1137,28 +1283,16 @@ mod tests { #[test] fn run_scopes_confine_job_reads_by_kind_and_path() { let confinement = - job_read_run_confinement(Some(&["jobs:run:flows:f/team/*".to_string()])).unwrap(); - assert!(run_confinement_admits(&confinement, "flows", "f/team/etl")); + job_read_confinement(Some(&["jobs:run:flows:f/team/*".to_string()])).unwrap(); + assert!(confinement.admits("flows", "f/team/etl")); // Right path, wrong kind — a script named like the flow is not the flow. - assert!(!run_confinement_admits( - &confinement, - "scripts", - "f/team/etl" - )); - assert!(!run_confinement_admits( - &confinement, - "flows", - "f/other/etl" - )); + assert!(!confinement.admits("scripts", "f/team/etl")); + assert!(!confinement.admits("flows", "f/other/etl")); // A kind-only scope confines to that kind, at any path. - let kind_only = job_read_run_confinement(Some(&["jobs:run:scripts".to_string()])).unwrap(); - assert!(run_confinement_admits( - &kind_only, - "scripts", - "u/admin/anything" - )); - assert!(!run_confinement_admits(&kind_only, "flows", "f/team/etl")); + let kind_only = job_read_confinement(Some(&["jobs:run:scripts".to_string()])).unwrap(); + assert!(kind_only.admits("scripts", "u/admin/anything")); + assert!(!kind_only.admits("flows", "f/team/etl")); // Scopes that grant job reads in their own right leave reads unconfined. for scopes in [ @@ -1171,11 +1305,111 @@ mod tests { vec!["if_jobs:filter_tags:deno".to_string()], ] { assert!( - job_read_run_confinement(Some(&scopes)).is_none(), + job_read_confinement(Some(&scopes)).is_none(), "{scopes:?} must not confine job reads" ); } - assert!(job_read_run_confinement(None).is_none()); + assert!(job_read_confinement(None).is_none()); + } + + #[test] + fn jobs_read_paths_confine_reads_and_reach_only_confined_routes() { + let scopes = |s: &[&str]| s.iter().map(|s| s.to_string()).collect::>(); + let scoped = scopes(&["jobs:read:f/served/*,u/svc/report"]); + let confinement = job_read_confinement(Some(&scoped)).unwrap(); + // Any kind of runnable at a named path. + assert!(confinement.admits("flows", "f/served/etl")); + assert!(confinement.admits("scripts", "u/svc/report")); + assert!(!confinement.admits("scripts", "f/other/etl")); + assert!(!confinement.admits("scripts", "f/served_not/etl")); + let lists = |s: &[&str], path: &str| job_list_path_filter(Some(&scopes(s))).allows(path); + assert!(lists( + &["jobs:read:f/served/*,u/svc/report"], + "f/served/etl" + )); + assert!(!lists( + &["jobs:read:f/served/*,u/svc/report"], + "f/other/etl" + )); + + // A run scope adds the runnables it may start to the by-id reads, and nothing to + // the lists, not even a bare one that leaves the by-id reads unconfined. + let both = scopes(&["jobs:read:f/served/*", "jobs:run:scripts:f/other/etl"]); + let confinement = job_read_confinement(Some(&both)).unwrap(); + assert!(confinement.admits("scripts", "f/other/etl")); + assert!(!confinement.admits("flows", "f/other/etl")); + for run in ["jobs:run:scripts:f/other/etl", "jobs:run"] { + assert!( + !lists(&["jobs:read:f/served/*", run], "f/other/etl"), + "{run}" + ); + } + for unfiltered in [ + &["jobs:read"][..], + &["jobs:read:f/served/*", "jobs:write"], + &["if_jobs:filter_tags:deno"], + ] { + assert!(lists(unfiltered, "f/other/etl"), "{unfiltered:?}"); + } + + for unconfined in [ + &["jobs:read:*"][..], + &["jobs:read:f/served/*", "jobs:read"], + &["jobs:read:f/served/*", "jobs:write"], + &["jobs:write:f/served/*"], + ] { + assert!( + job_read_confinement(Some(&scopes(unconfined))).is_none(), + "{unconfined:?}" + ); + } + + let id = "0190f4c2-0000-7000-8000-000000000000"; + for route in [ + format!("jobs_u/get/{id}"), + format!("jobs_u/completed/get_result/{id}"), + format!("jobs_u/getupdate_sse/{id}"), + format!("jobs_u/flow/approval_info/{id}"), + "jobs/list".to_string(), + "jobs/completed/list".to_string(), + "jobs/queue/list".to_string(), + ] { + let route = format!("/api/w/ws/{route}"); + assert!( + check_route_access(&scoped, &route, "GET").is_ok(), + "{route}" + ); + } + // Reads that aggregate over the workspace, resolve past the job they name, or + // mint a credential stay with a `jobs:read` that names no path. + for route in [ + "jobs/list_filtered_uuids".to_string(), + "jobs/queue/list_filtered_uuids".to_string(), + "jobs/queue/count".to_string(), + "jobs/completed/count".to_string(), + "jobs/completed/count_jobs".to_string(), + "jobs/completed/export".to_string(), + "jobs/queue/export".to_string(), + "jobs/asset_dispatch_edges".to_string(), + format!("jobs/queue/scheduled_for/{id}"), + format!("jobs/result_by_id/{id}/a"), + format!("jobs/job_view_token/{id}"), + format!("jobs_u/get_root_job_id/{id}"), + ] { + let route = format!("/api/w/ws/{route}"); + assert!( + check_route_access(&scoped, &route, "GET").is_err(), + "{route}" + ); + assert!( + check_route_access(&scopes(&["jobs:read"]), &route, "GET").is_ok(), + "{route}" + ); + assert!( + check_route_access(&scopes(&["jobs:read:*"]), &route, "GET").is_ok(), + "{route}" + ); + } } #[test] @@ -1585,6 +1819,6 @@ mod tests { } // A cancel scope neither confines nor frees a run token's reads. let run = scopes(&["jobs:run:flows:f/a/b", "jobs:cancel"]); - assert!(job_read_run_confinement(Some(&run)).is_some()); + assert!(job_read_confinement(Some(&run)).is_some()); } } diff --git a/backend/windmill-api-jobs/src/query.rs b/backend/windmill-api-jobs/src/query.rs index 857b2e02e2..96d8ca3698 100644 --- a/backend/windmill-api-jobs/src/query.rs +++ b/backend/windmill-api-jobs/src/query.rs @@ -26,6 +26,34 @@ fn not_in_nullable(col: &str, quoted: &[String]) -> String { ) } +/// Narrows a job list to the top-level runs of deployed scripts and flows at an `exact` +/// path, or at or under a `prefix` on the `/` boundary (`ScopePathFilter::allows`). +/// +/// Top-level only because every `runnable_path` index on `v2_job` is partial on +/// `parent_job IS NULL`. Deployed kinds only because a preview's `runnable_path` is +/// whatever its caller sent. +pub fn and_where_root_run_at_paths(sqlb: &mut SqlBuilder, exact: &[String], prefix: &[String]) { + let mut clauses = Vec::new(); + let equal: Vec<_> = exact.iter().chain(prefix).map(|p| quote(p)).collect(); + if !equal.is_empty() { + clauses.push(format!("v2_job.runnable_path IN ({})", equal.join(", "))); + } + for p in prefix { + let pattern = format!("{}/%", escape_ilike_pattern(p)); + clauses.push(format!("v2_job.runnable_path LIKE {}", quote(&pattern))); + } + if clauses.is_empty() { + sqlb.and_where("false"); + return; + } + sqlb.and_where_is_null("v2_job.parent_job") + .and_where( + "v2_job.kind IN ('script', 'script_hub', 'unassigned_script', 'flow', \ + 'unassigned_flow', 'singlestepflow', 'unassigned_singlestepflow')", + ) + .and_where(format!("({})", clauses.join(" OR "))); +} + pub fn filter_list_queue_query( mut sqlb: SqlBuilder, lq: &ListQueueQuery, diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs index b844e8c2bf..76f729e7e0 100644 --- a/backend/windmill-api/src/agent_runs.rs +++ b/backend/windmill-api/src/agent_runs.rs @@ -29,7 +29,7 @@ use crate::db::{ApiAuthed, DB}; use crate::jobs::{handle_chat_conversation_messages, set_flow_memory_id, RunJobQuery}; /// The id the agent's step carries in the run, as the editor's chat names it. A token scoped to -/// `jobs:run:agents:` reads its runs back by it (`require_job_within_run_scope`). +/// `jobs:run:agents:` reads its runs back by it (`require_job_within_read_scope`). const AGENT_NODE_ID: &str = "__wm_agent_root"; /// A turn of the agent's chat when `memory_id` names a conversation, a single run otherwise. diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 6ce04243d3..1f92c6d425 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -498,6 +498,28 @@ async fn get_result_by_id( view_token.as_deref(), ) .await?; + // The node is resolved up through the enclosing flows, so a read path that admitted + // `flow_id` as a step must also admit the run it is a step of. A path-scoped + // `jobs:read` does not reach this route; a `jobs:run` scope beside it does. + if windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref()) + .is_some_and(|c| c.has_read_paths()) + { + let top = sqlx::query_scalar::<_, Uuid>( + "WITH RECURSIVE chain(id, parent_job) AS ( + SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2 + UNION ALL + SELECT j.id, j.parent_job FROM v2_job j + JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2 + ) + SELECT id FROM chain WHERE parent_job IS NULL", + ) + .bind(flow_id) + .bind(&w_id) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("Job {flow_id} not found")))?; + require_job_within_read_scope(&db, &authed, &w_id, &top).await?; + } let res = windmill_queue::get_result_by_id(db.clone(), w_id.clone(), flow_id, node_id, json_path) @@ -1729,7 +1751,7 @@ async fn require_job_access( job_id: &Uuid, created_by: &str, view_token: Option<&str>, - run_confined: bool, + read_confined: bool, ) -> error::Result<()> { // Tag scope (`if_jobs:filter_tags:`) is an orthogonal hard restriction on a // scoped token: it must never read a job outside its allowed tags, regardless of @@ -1754,11 +1776,11 @@ async fn require_job_access( } } - // A path-scoped `jobs:run` token is likewise hard-restricted to the runnables it - // may start, ahead of every grant below — the token is handed out to run one thing, - // so it must not read jobs of anything else merely because its owner could. - if run_confined { - require_job_within_run_scope(db, authed, w_id, job_id).await?; + // A path-scoped `jobs:run` or `jobs:read` token is likewise hard-restricted to the + // runnables it names, ahead of every grant below — the token is handed out for one + // thing, so it must not read jobs of anything else merely because its owner could. + if read_confined { + require_job_within_read_scope(db, authed, w_id, job_id).await?; } // Fast path: you can always read a job you launched. This is also load-bearing @@ -1907,20 +1929,35 @@ async fn require_job_access( /// could start an app's inline-script component but not read the run back — those jobs /// are `AppScript`/`Preview` kinds that no `jobs:run` scope can name. /// -/// No-op — and no query — for every caller whose job reads are not run-confined (see -/// `job_read_run_confinement`), which is all sessions, unscoped tokens and `jobs:read` -/// tokens. -async fn require_job_within_run_scope( +/// A `jobs:read:` scope confines the same way, by path alone: the job, or one of +/// its ancestors, must be a run of a deployed script, flow or agent at a named path. +/// Only those kinds count, because a preview's `runnable_path` is whatever its caller +/// sent and would otherwise let any preview pass for a named runnable. +/// +/// No-op — and no query — for every caller whose job reads are not confined (see +/// `job_read_confinement`), which is all sessions, unscoped tokens and tokens whose +/// `jobs:read` names no path. +async fn require_job_within_read_scope( db: &DB, authed: &ApiAuthed, w_id: &str, job_id: &Uuid, ) -> error::Result<()> { let Some(confinement) = - windmill_api_auth::scopes::job_read_run_confinement(authed.scopes.as_deref()) + windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref()) else { return Ok(()); }; + // A job whose token is restricted by `job_token_scopes` still reads its own flow run + // (step results, user state), which the route check lets through whatever the + // scopes. A read path names what else it may read, not a fence around its own run. + if let Some(own_job) = authed.job_id { + if confinement.has_read_paths() + && windmill_api_auth::auth::job_in_same_flow_run(db, own_job, *job_id).await + { + return Ok(()); + } + } // `scope_kind` is the runnable kind a `jobs:run::` scope can name, or // NULL for a job no such scope reaches directly (previews, dependency jobs, // flow-inlined scripts) — those are still readable as a step of a matching flow, @@ -1968,13 +2005,7 @@ async fn require_job_within_run_scope( let in_scope = chain.iter().any( |job| match (job.runnable_path.as_deref(), job.scope_kind.as_deref()) { - (Some(runnable_path), Some(kind)) - if windmill_api_auth::scopes::run_confinement_admits( - &confinement, - kind, - runnable_path, - ) => - { + (Some(runnable_path), Some(kind)) if confinement.admits(kind, runnable_path) => { true } _ => job.launched_by_app.as_deref().is_some_and(&runs_app), @@ -1996,7 +2027,7 @@ async fn require_job_within_run_scope( /// is within a scope on the flow. Only those kinds count, because a preview's /// `runnable_path` is whatever its caller sent and would otherwise let any preview /// impersonate an in-scope runnable. Agent runs are previews filed under the agent's -/// path, recognized the same way `require_job_within_run_scope` does. +/// path, recognized the same way `require_job_within_read_scope` does. async fn filter_jobs_within_cancel_scope( db: &DB, authed: &ApiAuthed, @@ -2270,7 +2301,7 @@ async fn require_job_cancel_access( .fetch_optional(db) .await? .ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?; - let run_confined = !windmill_api_auth::scopes::has_job_cancel_grant(authed.scopes.as_deref()); + let read_confined = !windmill_api_auth::scopes::has_job_cancel_grant(authed.scopes.as_deref()); require_job_access( db, user_db, @@ -2279,7 +2310,7 @@ async fn require_job_cancel_access( job_id, &created_by, None, - run_confined, + read_confined, ) .await?; require_job_within_cancel_scope(db, authed, w_id, *job_id).await @@ -2415,7 +2446,7 @@ async fn get_job( // an approval link for a job must not let a scoped token read one outside the // runnables it may start. if let Some(authed) = opt_authed.as_ref() { - require_job_within_run_scope(&db, authed, &w_id, &id).await?; + require_job_within_read_scope(&db, authed, &w_id, &id).await?; } } else { require_opt_authed_job_read_access( @@ -4390,6 +4421,17 @@ struct ListableQueuedJob { pub workspace_id: String, } +/// Narrows a job list to what a path-scoped `jobs:read:` token may read: the +/// top-level runs of the runnables it names. A step is read by id, under its flow. No-op +/// for every caller whose lists are not path-filtered (see `job_list_path_filter`). +fn confine_list_to_read_scope(sqlb: &mut SqlBuilder, authed: &ApiAuthed) { + if let windmill_api_auth::ScopePathFilter::Restricted { exact, prefix } = + windmill_api_auth::scopes::job_list_path_filter(authed.scopes.as_deref()) + { + and_where_root_run_at_paths(sqlb, &exact, &prefix); + } +} + async fn list_queue_jobs( authed: ApiAuthed, Extension(user_db): Extension, @@ -4411,7 +4453,7 @@ async fn list_queue_jobs( "null as args" }; - let sql = list_queue_jobs_query( + let mut sqlb = list_queue_jobs_query( &w_id, &lq, &[ @@ -4441,8 +4483,9 @@ async fn list_queue_jobs( pagination, false, get_scope_tags(&authed), - ) - .sql()?; + ); + confine_list_to_read_scope(&mut sqlb, &authed); + let sql = sqlb.sql()?; let mut tx = user_db.begin(&authed).await?; let jobs = sqlx::query_as::<_, ListableQueuedJob>(&sql) .fetch_all(&mut *tx) @@ -4863,7 +4906,7 @@ async fn list_jobs( } let sqlc = if lq.running.is_none() { - Some(list_completed_jobs_query( + let mut sqlc = list_completed_jobs_query( &w_id, Some(per_page), 0, @@ -4871,7 +4914,9 @@ async fn list_jobs( cj_fields_ref, true, get_scope_tags(&authed), - )) + ); + confine_list_to_read_scope(&mut sqlc, &authed); + Some(sqlc) } else { None }; @@ -4898,6 +4943,7 @@ async fn list_jobs( true, get_scope_tags(&authed), ); + confine_list_to_read_scope(&mut sqlq, &authed); if let Some(sqlc) = sqlc { format!("{} UNION ALL {}", &sqlq.subquery()?, &sqlc.subquery()?,) @@ -5321,6 +5367,11 @@ async fn get_approval_info( "Must be logged in or provide a valid approval token".to_string(), )); } + // Nothing below goes through `require_job_read_access`, and an approval token is no + // way around a scoped token's confinement. + if let Some(authed) = opt_authed.as_ref() { + require_job_within_read_scope(&db, authed, &w_id, &job_id).await?; + } // Fetch job info #[derive(sqlx::FromRow)] @@ -5553,8 +5604,17 @@ async fn get_approval_info( // Possession of view rights over this approval is sufficient to mint a // share-read-link token for the flow: it only grants read (no resume), and only to // an authenticated workspace member, so it never widens what the approver can do. - let hmac = generate_view_token(&w_id, row.id, VIEW_TOKEN_DOMAIN, &db).await?; - let view_token = Some(format!("{}.{hmac}", row.id)); + // Not for a token whose reads are confined: the link would outlive it and serve any + // member, which is why `job_view_token` is refused to it too. + let confined = opt_authed.as_ref().is_some_and(|authed| { + windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref()).is_some() + }); + let view_token = if confined { + None + } else { + let hmac = generate_view_token(&w_id, row.id, VIEW_TOKEN_DOMAIN, &db).await?; + Some(format!("{}.{hmac}", row.id)) + }; Ok(Json(ApprovalInfo { flow_id: row.id, @@ -6016,7 +6076,7 @@ pub async fn get_suspended_job_flow( // resume secret must not let a scoped token read a flow it may not run. Anonymous // approvers are unaffected. if let Some(authed) = authed.as_ref() { - require_job_within_run_scope(&db, authed, &w_id, &flow_id).await?; + require_job_within_read_scope(&db, authed, &w_id, &flow_id).await?; } let flow = GetQuery::new() @@ -6200,7 +6260,7 @@ pub async fn get_flow_user_state( // Reachable by a `jobs:run` token (it is one of the by-id routes a run needs), so // apply the same run-scope confinement as the other single-job reads. RLS below // still governs which jobs the owner's identity can see at all. - require_job_within_run_scope(&db, &authed, &w_id, &job_id).await?; + require_job_within_read_scope(&db, &authed, &w_id, &job_id).await?; let mut tx = user_db.begin(&authed).await?; let r = sqlx::query_scalar!( r#" @@ -11429,7 +11489,7 @@ async fn list_completed_jobs( "null as args" }; - let sql = list_completed_jobs_query( + let mut sqlb = list_completed_jobs_query( &w_id, Some(per_page), offset, @@ -11469,8 +11529,9 @@ async fn list_completed_jobs( ], false, get_scope_tags(&authed), - ) - .sql()?; + ); + confine_list_to_read_scope(&mut sqlb, &authed); + let sql = sqlb.sql()?; let mut tx = user_db.begin(&authed).await?; let jobs = sqlx::query_as::<_, ListableCompletedJob>(&sql) .fetch_all(&mut *tx) @@ -11649,7 +11710,7 @@ async fn get_completed_job_result( // confinement that gate carries — re-apply it, as `get_job` does for the // approval token. Anonymous approval access is untouched. if let Some(authed) = opt_authed.as_ref() { - require_job_within_run_scope(&db, authed, &w_id, &id).await?; + require_job_within_read_scope(&db, authed, &w_id, &id).await?; } } else { require_opt_authed_job_read_access( diff --git a/backend/windmill-api/src/token.rs b/backend/windmill-api/src/token.rs index 53dfb2a214..b7fbe96404 100644 --- a/backend/windmill-api/src/token.rs +++ b/backend/windmill-api/src/token.rs @@ -193,7 +193,7 @@ lazy_static! { ScopeOption { value: "jobs:read".to_string(), label: "Read".to_string(), - requires_resource_path: false, + requires_resource_path: true, }, ScopeOption { value: "jobs:write".to_string(), From 5912d300a550a33674fd73740d613d1b434fbba1 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 6 Oct 2026 14:35:26 +0200 Subject: [PATCH 058/174] fix: group raw app edits that carry a file diff in the ai chat (#11558) Co-authored-by: Claude Opus 5.5 (1M context) --- .../lib/components/copilot/chat/ToolDiffCard.svelte | 5 +++-- .../copilot/chat/ToolExecutionDisplay.svelte | 2 +- .../lib/components/copilot/chat/toolGroups.test.ts | 13 +++++++++++++ .../src/lib/components/copilot/chat/toolGroups.ts | 4 +++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte index b360dc3ac5..94e33374c3 100644 --- a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte @@ -13,9 +13,10 @@ interface Props { message: ToolDisplayMessage + hidePreviewChip?: boolean } - let { message }: Props = $props() + let { message, hidePreviewChip = false }: Props = $props() const diff = $derived(toolCodeDiff(message)) const isStreaming = $derived(Boolean(message.isStreamingArguments)) @@ -56,7 +57,7 @@ const isRunning = $derived(Boolean(message.isLoading && !message.needsConfirmation)) const showPreviewChip = $derived( - Boolean(message.previewCard && !message.isLoading && !message.error) + Boolean(!hidePreviewChip && message.previewCard && !message.isLoading && !message.error) ) diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 15a7e2ea66..7e92c55c89 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -197,7 +197,7 @@ {:else if isRunCard} {:else if isDiffCard} - + {:else if planState} diff --git a/frontend/src/lib/components/copilot/chat/toolGroups.test.ts b/frontend/src/lib/components/copilot/chat/toolGroups.test.ts index a4fc4ce0a8..4dd3b376fe 100644 --- a/frontend/src/lib/components/copilot/chat/toolGroups.test.ts +++ b/frontend/src/lib/components/copilot/chat/toolGroups.test.ts @@ -187,6 +187,19 @@ describe('groupToolRuns', () => { ).toEqual([{ edit: [0, 1, 2] }, { edit: [3, 4] }]) }) + it('folds app edits that carry a file diff, but not a script edit with one', () => { + const app = { path: 'f/a/x' } + const diff = { codeDiff: { before: 'a', after: 'b' } } + expect( + shape([ + tool('init_app', app), + tool('write_app_file', { ...app, file_path: '/App.tsx' }, diff), + tool('delete_app_runnable', { ...app, key: 'greet' }, diff), + tool('write_script', { path: 'f/a/s' }, diff) + ]) + ).toEqual([{ edit: [0, 1, 2] }, 3]) + }) + it('leaves a flow read that prepares an edit to the edit group, not the lookups before it', () => { const flow = { type: 'flow', path: 'f/a/flow' } expect( diff --git a/frontend/src/lib/components/copilot/chat/toolGroups.ts b/frontend/src/lib/components/copilot/chat/toolGroups.ts index 94936d4293..f3e3ad0aa9 100644 --- a/frontend/src/lib/components/copilot/chat/toolGroups.ts +++ b/frontend/src/lib/components/copilot/chat/toolGroups.ts @@ -116,7 +116,9 @@ function groupableCall(message: DisplayMessage): ToolDisplayMessage | undefined if (message.needsConfirmation || message.blockedByPlanMode) return undefined if (message.heldForFolderInstructions) return undefined if (message.runForm || message.inspectedRun || message.userQuestion) return undefined - if (message.codeDiff || message.imageUrl || message.webSearchSources) return undefined + // An app edit's diff covers one file of many, so it stays a row inside the app's group. + if (message.codeDiff && EDIT_TOOLS[message.toolName] !== 'app') return undefined + if (message.imageUrl || message.webSearchSources) return undefined if (!message.error && webSearchResultOf(message.result)) return undefined // The user's own refusal is a decision, not a failure to fold away. if (message.declinedByUser) return undefined From 5f92daab6d0ecac30dd384e1b0f302af13fdd9ab Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:40:56 +0200 Subject: [PATCH 059/174] fix: serve the raw-app preview shell sandboxed, load a blob copy in the editor (#11554) * fix: serve the raw-app preview shell sandboxed, load a blob copy in the editor Co-Authored-By: Claude Opus 5.5 * fix: give the preview shell the deployed app wrapper's sandbox flags Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-api/src/static_assets.rs | 13 +++++ .../components/raw_apps/RawAppEditor.svelte | 56 +++++++++++++++---- frontend/vite.config.js | 19 +++++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 66dc4da9df..78c2b48f3b 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -100,6 +100,19 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response< res = res.header("X-Robots-Tag", "noindex, nofollow"); } + // The raw-app preview shell evaluates whatever js is posted to it, so it is + // only ever served opaque-origin, with the deployed app wrapper's exact flags + // (`get_raw_app_data`). Matched on the embedded file, not the request path, + // so `//ui_builder/...` is covered. The editor loads a same-origin blob: copy. + if path == "ui_builder/app-preview.html" { + res = res.header( + header::CONTENT_SECURITY_POLICY, + "sandbox allow-scripts allow-forms allow-popups \ + allow-popups-to-escape-sandbox allow-downloads allow-modals \ + allow-top-navigation", + ); + } + // Add Content-Security-Policy header for static assets when policy is set if !CSP_POLICY.is_empty() { if let Ok(header_value) = HeaderValue::try_from(CSP_POLICY.as_str()) { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index fd0bd23c53..25b2e75a4c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -382,6 +382,30 @@ let iframe: HTMLIFrameElement | undefined = $state(undefined) const PREVIEW_SHELL_URL = '/ui_builder/app-preview.html' + // The served shell is `CSP: sandbox` (opaque origin), but the editor reads the + // preview's DOM and feeds it builds as a same-origin document, so it loads a + // per-mount blob: copy instead — the same split as `unsandboxedRawAppHtml`, and + // like it with no ``, so the app resolves URLs as it will once deployed. + let previewShellUrl: string | undefined = $state(undefined) + onMount(() => { + let url: string | undefined + let destroyed = false + fetch(PREVIEW_SHELL_URL) + .then((res) => { + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return res.text() + }) + .then((html) => { + if (destroyed) return + url = URL.createObjectURL(new Blob([html], { type: 'text/html' })) + previewShellUrl = url + }) + .catch((e) => sendUserToast(`Could not load the app preview: ${e}`, true)) + return () => { + destroyed = true + if (url) URL.revokeObjectURL(url) + } + }) let previewIframe: HTMLIFrameElement | undefined = $state(undefined) let previewIframeLoaded = $state(false) let lastBuild: { css: string; js: string } | undefined = undefined @@ -1550,15 +1574,15 @@ * realm, so only a reload drops the old bundle's timers, listeners and the * token its client captured at module load. */ function restartPreviewRealm() { - if (!lastBuild) return // nothing running yet + if (!lastBuild || !previewShellUrl) return // nothing running yet previewIframeLoaded = false - if (previewIframe) previewIframe.src = PREVIEW_SHELL_URL + if (previewIframe) previewIframe.src = previewShellUrl if (externalPreviewWindow && !externalPreviewWindow.closed) { externalPreviewReady = false // User app code can navigate this window elsewhere, which makes its // location cross-origin and unreachable — that document holds no token. try { - externalPreviewWindow.location.replace(PREVIEW_SHELL_URL) + externalPreviewWindow.location.replace(previewShellUrl) } catch (_) {} } } @@ -1671,7 +1695,11 @@ } // Scope the window name per app path so two open editors don't fight over // (or take over / close) one shared OS-level preview window. - const win = window.open(PREVIEW_SHELL_URL, `windmillRawAppPreview:${encodeURIComponent(path)}`) + if (!previewShellUrl) { + sendUserToast('The app preview is not available', true) + return + } + const win = window.open(previewShellUrl, `windmillRawAppPreview:${encodeURIComponent(path)}`) if (!win) { sendUserToast('Could not open the preview window (popup blocked?)', true) return @@ -2003,8 +2031,8 @@ return () => restores.forEach((r) => r()) } - // Capture the live preview as a PNG data URL. The preview iframe - // (/ui_builder/app-preview.html) is same-origin with no sandbox, so its rendered + // Capture the live preview as a PNG data URL. The preview iframe (the blob: copy + // of the shell) is same-origin with no sandbox, so its rendered // document is reachable and can be serialized from here. There is no native // element-screenshot API; modern-screenshot reconstructs the DOM into an SVG // foreignObject, so a WebGL canvas is only captured when its context was created @@ -2722,12 +2750,16 @@
{/snippet} - + + {#if previewShellUrl} + + {/if} {#if buildError} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 099dbe246a..5ae1e390e3 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -210,6 +210,17 @@ function needsCrossOriginIsolation(url) { ) } +// Normalized like the static server resolves it, so `//ui_builder/...` or an +// encoded name can't reach the file without the header. +function isPreviewShell(url) { + let path = url.split('?')[0] + try { + path = decodeURIComponent(path) + } catch {} + path = path.replace(/\/+/g, '/') + return path === '/ui_builder/app-preview.html' +} + let plugin = { name: 'configure-response-headers', enforce: 'pre', @@ -219,6 +230,14 @@ let plugin = { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') } + // Mirrors `static_assets.rs`. Set before the static handler and the :4000 + // proxy fallback alike, so both paths serve the shell sandboxed. + if (isPreviewShell(req.url ?? '')) { + res.setHeader( + 'Content-Security-Policy', + 'sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation' + ) + } // CORP on everything so dev assets stay loadable as subresources of // isolated documents on other dev origins (e.g. 127.0.0.1 vs localhost). res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin') From f381f79fb9a542fabc3e275c5b0d439ccf3057cf Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 6 Oct 2026 14:44:39 +0200 Subject: [PATCH 060/174] ignore self-hosted subscriptions in a team workspace's premium info (#11552) * fix: ignore self-hosted subscriptions in a team workspace's premium info Bumps ee-repo-ref to windmill-labs/windmill-ee-private#846. Co-Authored-By: Claude Opus 5.5 * Update commit hash in ee-repo-ref.txt --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- 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 24c44aa112..f00c5de5f8 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -b5a8e3631f657fbb0a7b4b4ef89ddb9ed2c43b1d +154cbccb6b8d47bf662b5507882917b5933d5a45 From fd8de45cc7cfcba2c374dd86f87f4d3e8aec6ded Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 6 Oct 2026 14:45:03 +0200 Subject: [PATCH 061/174] feat: open a run as a pushed page on the narrow runs page (#11541) * feat: open a run as a pushed page on narrow runs page Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep modifier-click multi-select and arrow keys on the narrow runs list Co-Authored-By: Claude Opus 5.5 (1M context) * fix: leave the narrow re-run page once the re-run is sent, and clear the opened run on reset Co-Authored-By: Claude Opus 5.5 (1M context) * fix: close the re-run options once a table re-run completes Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/lib/components/RunsPage.svelte | 601 ++++++++++-------- .../src/lib/components/runs/RunsTable.svelte | 1 + 2 files changed, 353 insertions(+), 249 deletions(-) diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 97f2723943..ff32cbdbd7 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -51,10 +51,11 @@ import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte' import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils' import type { BatchReRunOptions } from '$lib/components/runs/BatchReRunOptionsPane.svelte' - import { untrack } from 'svelte' + import { untrack, type Snippet } from 'svelte' import { page } from '$app/state' import Select from '$lib/components/select/Select.svelte' import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte' + import PagedContent from '$lib/components/common/modal/PagedContent.svelte' import { StaleWhileLoading, useLocalStorageValue } from '$lib/svelte5Utils.svelte' import { Calendar, @@ -63,8 +64,9 @@ CirclePlay, Clock, Hourglass, - Loader2, - TriangleAlertIcon + TriangleAlertIcon, + ArrowLeft, + Loader2 } from 'lucide-svelte' import DropdownV2 from './DropdownV2.svelte' import TimeframeSelect, { @@ -208,6 +210,7 @@ function reset() { _timeframe.val = { ...runsTimeframes[0] } selectedIds = [] + openedRunId = undefined delete filters.val.schedule_path selectedWorkspace = undefined jobsLoader?.loadJobs(true) @@ -498,6 +501,9 @@ }) selectedIds = [] + // Opened from the table's "Run again", the options are done; left open, the next selection + // of one run would show them again. The re-run selection mode keeps them for its next batch. + if (!manualSelectionMode) batchRerunOptionsIsOpen = false jobsLoader?.loadJobs(true, true) } @@ -581,6 +587,27 @@ ) let manualSelectionMode: undefined | 'cancel' | 'rerun' | 'resolve' = $state() + + // Below 1024px (Tailwind `lg`) the side pane leaves the table too little room, so what it would + // show becomes a page pushed over the list. Selection modes keep the split: their rows are + // picked in the list while the pane holds the action. + const pagedLayout = $derived(innerWidth < 1024 && !manualSelectionMode) + // Set only by a plain row click, a chart point or the arrow keys, never by a modifier click: + // a Ctrl/Cmd/Shift-click starting a multi-selection selects one run first, and opening it would + // hide the list before the second click. + let openedRunId: string | undefined = $state() + const runPageOpen = $derived( + // A confirmed re-run clears the selection but leaves the options flag set. + (batchRerunOptionsIsOpen && selectedIds.length > 0) || + (selectedIds.length === 1 && selectedIds[0] === openedRunId) + ) + + // Clears the selection too: a re-run selection of one job would otherwise land on that job. + function backToList() { + batchRerunOptionsIsOpen = false + selectedIds = [] + openedRunId = undefined + } let resolutionNote = $state('') @@ -929,6 +956,7 @@ _timeframe.val = buildManualTimeframe(zoom.min.toISOString(), zoom.max.toISOString()) }} onPointClicked={(ids) => { + openedRunId = ids.length === 1 ? ids[0] : undefined runsTable?.scrollToRun(ids) }} /> @@ -946,256 +974,331 @@
- - -
-
- {#if scanProgress} -
- -
- jobsLoader.stopBatchLoading()} - /> -
-
- {/if} - {#if batchProgress} -
- jobsLoader.restreamWithBatchSize(v)} - onStop={() => jobsLoader.stopBatchLoading()} - /> -
- {/if} - -
- {#if jobs} - - {:else} -
- {#each new Array(8) as _} - - {/each} -
- {/if} -
-
- {#if !manualSelectionMode} - ((manualSelectionMode = 'cancel'), (selectedIds = [])) - }, - { - displayName: 'Re-run jobs', - action: () => { - manualSelectionMode = 'rerun' - selectedIds = [] - batchRerunOptionsIsOpen = true - } - }, - ...(!$userStore?.operator - ? [ - { - // Operators are rejected by the endpoint, so offering it would only 403. - displayName: 'Resolve failed jobs', - action: () => ( - (manualSelectionMode = 'resolve'), - (selectedIds = []), - (resolutionNote = '') - ) - } - ] - : []), - { - displayName: 'Cancel all jobs matching filters', - action: () => onCancelAllJobsMatchingFilters() - }, - { - displayName: 'Re-run all jobs matching filters', - action: () => onRerunAllJobsMatchingFilters() - } - ]} - /> - {:else} - - {/if} -
- - perPage.val, + (newPerPage) => { + perPage.val = newPerPage + if (newPerPage > (jobs?.length ?? 1000)) loadExtra() + } + } + onCreateItem={(v) => (perPage.val = parseInt(v))} + items={[ + { value: 25, label: '25' }, + { value: 100, label: '100' }, + { value: 1000, label: '1000' }, + { value: 10000, label: '10000' } + ]} + transformInputSelectedText={(_, v) => `${v} / page`} + /> +
+
+
+{/snippet} + +{#snippet sidePanel()} +
+ {#if manualSelectionMode === 'cancel'} +
+ +
+ {:else if manualSelectionMode === 'resolve'} +
+

+ Resolving keeps the run a failure but stops it showing as one in the runs list. +

+ +
+ + +
+
+ {:else if batchRerunOptionsIsOpen} + {@render batchRerunOptions()} + {:else if selectedIds.length === 1} + {#if selectedIds[0] === '-'} +
There is no information available for this job
+ {:else} + {@render jobPreview()} + {/if} + {:else if selectedIds.length > 1} +
+
{selectedIds.length} jobs selected
+
+ {/if} +
+{/snippet} + +{#snippet runPage()} + {@render runPageFrame(runPageBody)} +{/snippet} + +{#snippet runPagePlaceholder()} + {@render runPageFrame(runPageSkeleton)} +{/snippet} + +{#snippet runPageFrame(body: Snippet)} + +
+
+ +
+
+ {@render body()} +
+
+{/snippet} + +{#snippet runPageBody()} + {#if batchRerunOptionsIsOpen} + {@render batchRerunOptions()} + {:else if selectedIds[0] === '-'} +
There is no information available for this job
+ {:else if selectedIds.length === 1} + {@render jobPreview(runPageSkeleton)} + {/if} +{/snippet} + + +{#snippet runPageSkeleton()} + +{/snippet} + +{#snippet batchRerunOptions()} + {#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane} + ((batchRerunOptionsIsOpen = false), (manualSelectionMode = undefined))} + onConfirm={async (options) => { + await onReRunSelectedJobs(options) + }} + /> + {/await} +{/snippet} + +{#snippet jobPreview(pending?: Snippet)} + + {#await import('$lib/components/runs/JobRunsPreview.svelte')} + {@render pending?.()} + {:then JobRunsPreview} + jobsLoader?.loadJobs(true, true)} + /> + {/await} +{/snippet} + + - - Where dbt projects in this workspace run. A project names a warehouse by name in its descriptor (profile.warehouse) and reaches - {DEFAULT_WAREHOUSE} when it names none, so a project carries no - connection of its own. The name is also what its tables are keyed on in the asset graph (dbt://{DEFAULT_WAREHOUSE}/schema/table), so two projects on one warehouse share their nodes. Each entry points either at one of - Windmill's own connection resources, whose fields are translated into a dbt target, or at a - dbt_profile resource, which carries a - profiles.yml target as it is and so reaches any adapter dbt has. Configuring - one here is what makes it available: anyone who may run a dbt script builds with it and reads its models, - without being granted the resource, the same bargain workspace object storage makes. - +profile.warehouse in its descriptor, and gets ${DEFAULT_WAREHOUSE} when it names none. Point each warehouse at a Windmill connection resource, or at a dbt_profile resource holding a profiles.yml target for any other adapter. Anyone who can run a dbt script builds with these warehouses without being granted the resource.`} + link="https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt" +/> @@ -154,14 +146,10 @@ class="min-w-24" /> - - +
+ +
From 71901852fb3cc40515d6675a0a88cd158e942d56 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 7 Oct 2026 11:57:03 +0200 Subject: [PATCH 075/174] feat: set up a new app in one modal over its AI session, fix its deploy (#11582) * fix: set up a new app before starting with AI and follow its deploy Co-Authored-By: Claude Opus 5.5 (1M context) * feat: set up a new app in one modal over its ai session Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show schema creation progress and put the new app modal over the page Co-Authored-By: Claude Opus 5.5 (1M context) * fix: say when no worker can create a new app's schema instead of waiting Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop the summary from the new app modal Co-Authored-By: Claude Opus 5.5 (1M context) * feat: list existing tables only under an existing schema, explore from the schema row Co-Authored-By: Claude Opus 5.5 (1M context) * feat: start a new app from two cards, with ai recommended Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/common/ViewportOverlays.svelte | 10 + .../components/common/overlayHost.svelte.ts | 6 + .../copilot/chat/global/core.test.ts | 1 + .../components/copilot/chat/global/core.ts | 28 +- .../raw_apps/RawAppDataTableDrawer.svelte | 9 +- .../raw_apps/RawAppTemplatePicker.svelte | 661 ++++++++++-------- .../components/sessions/PreviewTabHost.svelte | 16 +- .../sessions/RawAppEditorView.svelte | 93 ++- .../sessions/SessionEditorTarget.svelte | 10 +- .../sessions/sessionPreviewTabs.svelte.ts | 20 + .../sessions/sessionPreviewTabs.test.ts | 15 + .../sessions/sessionRuntime.svelte.ts | 47 +- .../sessions/sessionSwitch.svelte.ts | 20 +- .../(root)/(logged)/apps_raw/add/+page.ts | 37 +- .../apps_raw/edit/[...path]/+page.svelte | 56 +- 15 files changed, 639 insertions(+), 390 deletions(-) create mode 100644 frontend/src/lib/components/common/ViewportOverlays.svelte diff --git a/frontend/src/lib/components/common/ViewportOverlays.svelte b/frontend/src/lib/components/common/ViewportOverlays.svelte new file mode 100644 index 0000000000..59dc2e55b6 --- /dev/null +++ b/frontend/src/lib/components/common/ViewportOverlays.svelte @@ -0,0 +1,10 @@ + + +{@render children?.()} diff --git a/frontend/src/lib/components/common/overlayHost.svelte.ts b/frontend/src/lib/components/common/overlayHost.svelte.ts index e556b51e67..824e239519 100644 --- a/frontend/src/lib/components/common/overlayHost.svelte.ts +++ b/frontend/src/lib/components/common/overlayHost.svelte.ts @@ -25,6 +25,12 @@ export function setOverlayHost(host: OverlayHostContext) { setContext(OVERLAY_HOST_KEY, host) } +/** Hand this subtree's overlays back to the viewport, for one that speaks for the whole + * page rather than the pane it happens to be opened from. */ +export function clearOverlayHost() { + setContext(OVERLAY_HOST_KEY, undefined) +} + export function getOverlayHost(): OverlayHostContext | undefined { return getContext(OVERLAY_HOST_KEY) } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index dd89a1fbec..692c78bfb8 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -4709,6 +4709,7 @@ describe('global AI tools', () => { expect(onDeployed).toHaveBeenCalledWith({ sessionId: 'sess-123', kind: 'raw_app', + storagePath: 'f/apps/report', path: 'f/apps/report' }) } finally { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e75f15d1eb..41bbad5abe 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -5038,6 +5038,9 @@ function getSessionScreenshot(sessionId: string | undefined): Promise void @@ -8651,24 +8654,29 @@ async function deployDraft( // synthetic storage key never exists deployed, so the entry would otherwise // stop matching anything after the draft is gone. const deployedKind = itemKindFor(type, triggerKind) + const draftStoragePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind) if (deployedKind) { - toolCallbacks.onItemDeployed?.( - deployedKind, - getGlobalDraftStoragePath(workspace, type, path, triggerKind), - deployedPath - ) + toolCallbacks.onItemDeployed?.(deployedKind, draftStoragePath, deployedPath) } - // Reload the session preview if it's open on the deployed item. Map the - // deploy type to the preview kind — a raw app deploys under 'app' but the - // preview addresses it as 'raw_app'; non-previewable types map to undefined. + // Reload the session preview if it's open on the deployed item, which its editor + // knows by the draft's storage path. Map the deploy type to the preview kind — a + // raw app deploys under 'app' but the preview addresses it as 'raw_app'; + // non-previewable types map to undefined. const previewKindByType: Partial> = { script: 'script', flow: 'flow', app: 'raw_app' } const kind = previewKindByType[type] - if (kind) deployedInSessionHandler?.({ sessionId, kind, path }) + if (kind) { + deployedInSessionHandler?.({ + sessionId, + kind, + storagePath: draftStoragePath, + path: deployedPath + }) + } toolCallbacks.setToolStatus(toolId, { content: `Deployed ${type} "${path}"`, @@ -8942,8 +8950,6 @@ export function prepareGlobalUserMessage( content += `path: ${activeEditor.path}\n` content += `isLiveDraft: true\n` if (activeEditor.isNew) { - // The template only lives in the open editor until the first edit saves it, so - // init_app would see no draft and overwrite it with a second app. content += 'isNew: true — the user just started this item from the new-item builder; it holds the starter template and is the item to build. Edit it in place; do not create another one.\n' } diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte index 7ccf89a275..af0af7ed5b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte @@ -256,11 +256,14 @@ selectedRole = undefined } - export function openDrawer() { + /** Opens on `at` when given, a data table and optionally one of its schemas. */ + export function openDrawer(at?: { datatable: string; schema?: string }) { resetSelection() - selectedSchemaKey = undefined + selectedSchemaKey = at?.schema selectedTableKey = undefined - selectDatatable(datatables.current.includes('main') ? 'main' : datatables.current[0]) + selectDatatable( + at?.datatable ?? (datatables.current.includes('main') ? 'main' : datatables.current[0]) + ) expand = false open = true } diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index 27ebdbda85..0928ddce7a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -6,13 +6,13 @@ List, Ban, ExternalLinkIcon, - Loader2, Code, - MessagesSquare, - WandSparkles + Database, + Loader2 } from 'lucide-svelte' - import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle' - import type { Policy } from '$lib/gen' + import { WorkerService, type Policy } from '$lib/gen' + import MissingWorkerTagAlert from '$lib/components/jobs/MissingWorkerTagAlert.svelte' + import { hasWorkerForTag, queuedWithoutWorkerMessage } from '$lib/components/jobs/missingWorker' import { superadmin, userStore } from '$lib/stores' import { base } from '$lib/base' import { sendUserToast } from '$lib/toast' @@ -22,8 +22,7 @@ import Select from '$lib/components/select/Select.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { Alert } from '$lib/components/common' - import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' + import { Alert, Badge } from '$lib/components/common' import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { react18Template, react19Template, svelte5Template } from './templates' @@ -52,11 +51,10 @@ files: Record runnables: Record data: RawAppData - summary: string policy: Policy } - /** `ai` hands the new app to the AI right away; `code` opens the editor on the setup's choices. */ + /** Where the set-up app opens: `ai` hands it to the AI, `code` opens it in the editor. */ export type RawAppBuildMode = 'ai' | 'code' let { @@ -68,18 +66,16 @@ } = $props() const templates = [ - { name: 'React 19', icon: 'tsx', files: react19Template }, + { name: 'React 19', icon: 'tsx', files: react19Template, recommended: true }, { name: 'React 18', icon: 'tsx', files: react18Template }, { name: 'Svelte 5', icon: 'svelte', files: svelte5Template } ] - let step = $state<'choice' | 'setup'>('choice') let selectedTemplateIndex = $state(0) let selectedDatatable = $state(undefined) let schemaMode = $state<'none' | 'new' | 'existing'>('new') let selectedSchema = $state(undefined) let newSchemaName = $state('') - let appSummary = $state('') let preWhitelistedTables = $state([]) /** The role each pre-whitelisted table's data table was browsed as. */ let preWhitelistedRoles = $state>({}) @@ -271,12 +267,7 @@ // would announce AI as unconfigured while it is merely unknown. Gate on the // config describing opWs, and load it here so the claim owns its own evidence. const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs) - // Say where the card leads: the route hands the app to a fresh AI session for - // everyone who has one, and opens the docked chat for the rest. - const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled) - // The AI starts on the default data table and its role, so both must have answered. - const dataSettled = $derived(hasNoDatatables || (rolesSettled && accessSettled)) $effect(() => { if (open && opWs && !aiConfigLoaded) { @@ -285,9 +276,11 @@ }) // With AI turned off for the workspace there is nothing to choose between. - const shownStep = $derived($copilotInfo.workspaceDisabled ? 'setup' : step) - const choiceCardClasses = - 'h-full flex-col items-start justify-start gap-3 rounded-lg p-6 text-left whitespace-normal' + const aiOffered = $derived(!$copilotInfo.workspaceDisabled) + + const canStart = $derived( + !!templates[selectedTemplateIndex] && !newSchemaAlreadyExists && rolesSettled && accessSettled + ) function appPolicy(): Policy { return { @@ -299,60 +292,103 @@ } } - /** The AI builds on the default template and the default data table: it picks the framework - * and creates the tables it needs itself. */ - function buildWithAI() { - const datatable = usableDatatable - const role = rolesAnswered ? effectiveRole : undefined - open = false - onStart( - { - files: templates[0].files, - runnables: {}, - data: { - tables: [], - datatable, - schema: undefined, - roles: datatable && role ? { [datatable]: role } : undefined - }, - summary: '', - policy: appPolicy() - }, - 'ai' + const startCardClasses = + 'relative h-full flex-col items-start justify-start gap-1.5 rounded-lg p-3 text-left whitespace-normal' + + async function createSchema(input: { + workspace: string + datatable: string + schema: string + role: string | undefined + migrationRole: string | undefined + }) { + try { + const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps') + const dbOps = dbSchemaOpsWithPreviewScripts({ + workspace: input.workspace, + input: { + type: 'database', + resourceType: 'postgresql', + resourcePath: `datatable://${input.datatable}`, + role: input.role, + migrationRole: input.migrationRole + } + }) + await dbOps.onCreateSchema({ schema: input.schema }) + } catch (e) { + console.error('Failed to create schema:', e) + sendUserToast(`Failed to create schema ${input.schema}: ${e}`, true) + } + } + + // The start that is in flight, and the schema it is waiting on: creating one is a job, + // which can sit in the queue long enough for a silent modal to look dead. + let starting = $state<{ mode: RawAppBuildMode; schema?: string } | undefined>(undefined) + + const SCHEMA_JOB_TAG = 'postgresql' + + /** Whether a worker can pick up the job that creates a schema. Only a definite "no" + * counts: with per-workspace default tags the job runs on a variant of the tag, and a + * failed lookup establishes nothing. */ + async function schemaJobsServed(workspace: string): Promise { + try { + if (await WorkerService.isDefaultTagsPerWorkspace()) return true + return await hasWorkerForTag(workspace, SCHEMA_JOB_TAG) + } catch { + return true + } + } + + // Existing tables go with an existing schema: under the other modes the list is not shown, + // and what is not shown is not saved. + const tablesToUse = $derived(schemaMode === 'existing' ? preWhitelistedTables : []) + + function exploreDatatable() { + dataTableDrawer?.openDrawer( + selectedDatatable + ? { + datatable: selectedDatatable, + schema: schemaMode === 'existing' ? selectedSchema : undefined + } + : undefined ) } - async function start() { + async function start(mode: RawAppBuildMode) { + if (starting) return const template = templates[selectedTemplateIndex] if (schemaMode === 'new' && newSchemaName && usableDatatable && opWs) { - try { - const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps') - const dbOps = dbSchemaOpsWithPreviewScripts({ - workspace: opWs, - input: { - type: 'database', - resourceType: 'postgresql', - resourcePath: `datatable://${usableDatatable}`, - role: effectiveRole, - migrationRole: defaultMigrationRole( - usableDatatable, - roles.current.permissioned, - roles.current.defaultRole - ) - } - }) - await dbOps.onCreateSchema({ schema: newSchemaName }) - } catch (e) { - console.error('Failed to create schema:', e) - sendUserToast(`Failed to create schema: ${e}`, true) + starting = { mode, schema: newSchemaName } + const creation = createSchema({ + workspace: opWs, + datatable: usableDatatable, + schema: newSchemaName, + role: effectiveRole, + migrationRole: defaultMigrationRole( + usableDatatable, + roles.current.permissioned, + roles.current.defaultRole + ) + }) + if (await schemaJobsServed(opWs)) { + await creation + } else { + // The job stays queued for as long as no worker serves its tag: waiting on it + // would hold the app back for nothing, so say why the schema is missing instead. + sendUserToast( + `Schema "${newSchemaName}" is not created yet. ${queuedWithoutWorkerMessage(SCHEMA_JOB_TAG)}`, + true + ) } } - const formattedTables = preWhitelistedTables.map(formatDataTableRef) + starting = undefined + + const formattedTables = tablesToUse.map(formatDataTableRef) const keepsDatatable = usableDatatable !== undefined // The roles shown, for the data tables the app ends up using. - const usedDatatables = new Set(preWhitelistedTables.map((t) => t.datatable)) + const usedDatatables = new Set(tablesToUse.map((t) => t.datatable)) if (keepsDatatable) usedDatatables.add(usableDatatable!) const shownRoles = Object.entries(pickerRoles ?? {}).filter(([dt]) => usedDatatables.has(dt)) const appRoles = shownRoles.length > 0 ? Object.fromEntries(shownRoles) : undefined @@ -371,10 +407,9 @@ files: template.files, runnables: {}, data, - summary: appSummary.trim(), policy: appPolicy() }, - 'code' + mode ) } @@ -385,240 +420,181 @@ Modal closes its own UI but the picker's `open` prop stays true, so the route's `templatePicker → false` watcher never fires and autosave stays suspended after the dismissal. --> - - {#if shownStep === 'choice'} -
-
- - + +
+
+

Framework

+
+ {#each templates as t, i} + + {/each}
-

You can always switch later.

- {#if aiConfigLoaded && !isAiEnabled} - - You can still create an app manually but using AI is highly recommended. +
+ +
+

Data configuration

+ + {#if hasNoDatatables} + + You can still create an app, but for data storage you won't be able to use data tables + which are highly recommended.
{#if $userStore?.is_admin} - Configure AI in + Configure datatables in workspace settings - - {#if $superadmin} - or - instance settings - - {/if} to enable this feature. - {:else if $superadmin} - Configure AI in - instance settings to enable this feature. {:else} - Ask your workspace admin to configure AI in workspace settings to enable this feature. + Ask your workspace admin to configure datatables in workspace settings to enable this + feature. {/if}
- {/if} -
- {:else} -
-
-

Summary

- -
- -
-

Framework

-
- {#each templates as t, i} - - {/each} -
-
- -
-

Data configuration

- - {#if hasNoDatatables} - - You can still create an app, but for data storage you won't be able to use data tables - which are highly recommended. -
- {#if $userStore?.is_admin} - Configure datatables in - workspace settings - to enable this feature. - {:else} - Ask your workspace admin to configure datatables in workspace settings to enable - this feature. - {/if} -
- {:else} -
-
- Default settings for new tables -
-
-
- -
+ {:else} +
+ +
+ Default settings for new tables +
+
+
+ +
+ ({ value: r, label: r }))} + bind:value={() => selectedRole, pickRole} + clearable={false} + placeholder="Role" size="sm" class="w-40" /> - {#if showRolePicker} - - as - -
- {/if} -
- {#if newSchemaAlreadyExists} - Schema "{newSchemaName}" already exists + {rolesUnknown + ? 'could not read its roles' + : noUsableRole + ? 'no role you can use' + : 'could not reach it'}; the app is created without a default data + table + {/if}
+
+ Schema +
+
+ + {#snippet children({ item })} + + + + {/snippet} + +
+
diff --git a/frontend/src/lib/components/breadcrumbPath.test.ts b/frontend/src/lib/components/breadcrumbPath.test.ts new file mode 100644 index 0000000000..427b153800 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { splitItemPath } from './breadcrumbPath' + +describe('splitItemPath', () => { + it('walks the scope, each subfolder, then the item, each with its cumulative path', () => { + expect(splitItemPath('f/demo/sub/weather_report')).toEqual({ + dirs: [ + { name: 'f/demo', fullPath: 'f/demo' }, + { name: 'sub', fullPath: 'f/demo/sub' } + ], + leaf: { name: 'weather_report', fullPath: 'f/demo/sub/weather_report' } + }) + }) + + it('gives up before there is a scope', () => { + expect(splitItemPath('f/demo')).toBeUndefined() + }) + + it('keeps the folders while the name is still being typed', () => { + // The editor binds the path live, so every keystroke reaches the breadcrumb: the trail must + // not drop out and come back on the first character of the name. + expect(splitItemPath('f/demo/')).toEqual({ + dirs: [{ name: 'f/demo', fullPath: 'f/demo' }], + leaf: undefined + }) + }) +}) diff --git a/frontend/src/lib/components/breadcrumbPath.ts b/frontend/src/lib/components/breadcrumbPath.ts new file mode 100644 index 0000000000..880d558360 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.ts @@ -0,0 +1,30 @@ +/** A directory level of an item's path, with the full path down to it. */ +export type PathDir = { name: string; fullPath: string } + +/** + * Splits `f/demo/weather_report` into the levels a breadcrumb walks: the scope (`f/` or + * `u/`), then each subfolder, then the item itself. Each level carries the cumulative path, + * which is what the picker opens and highlights. + * + * Returns undefined before there is even a scope. The leaf is undefined while the path names a + * folder but no item yet (`f/demo/`) — the levels it does name still come back. + */ +export function splitItemPath( + path: string +): { dirs: PathDir[]; leaf: PathDir | undefined } | undefined { + const parts = path.split('/') + if (parts.length < 3) return undefined + const scope = parts.slice(0, 2).join('/') + const slug = parts.slice(2) + const dirs: PathDir[] = [{ name: scope, fullPath: scope }] + let acc = scope + for (let i = 0; i < slug.length - 1; i++) { + acc = `${acc}/${slug[i]}` + dirs.push({ name: slug[i], fullPath: acc }) + } + // A leaf with an empty name draws a blank segment. The editor binds the path live, so the + // breadcrumb sees every keystroke: the folders stay put while the name is being typed rather + // than the whole trail dropping out and coming back. + const name = slug[slug.length - 1] + return { dirs, leaf: name ? { name, fullPath: path } : undefined } +} diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index f684d79a9f..f3cfc78478 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -148,6 +148,13 @@ }: Props = $props() const activeTone = $derived(dropdownItems ? undefined : tone) + // `dropdownItems` may be a function, whose `.length` is its arity — 0 for the `() => [...]` + // every caller writes. Asking it for its items is the only way to know whether there are any, + // and a split button that answers `.length` instead renders no caret at all. + const hasDropdown = $derived( + !!dropdownItems && + (typeof dropdownItems === 'function' ? dropdownItems().length : dropdownItems.length) > 0 + ) function computeDropdowns(menuItems: MenuItem[] | (() => MenuItem[])): Item[] { const items = typeof menuItems === 'function' ? menuItems() : menuItems @@ -197,12 +204,7 @@ ] : ButtonType.VariantStyles[variant] // For default variant with dropdowns, remove border from button since it's on wrapper - if ( - variant === 'default' && - dropdownItems && - ((typeof dropdownItems === 'function' && dropdownItems().length > 0) || - dropdownItems.length > 0) - ) { + if (variant === 'default' && hasDropdown) { style = style.replace('border border-border-light', '') } return style @@ -278,7 +280,7 @@ getSpacingClass(variant, size, spacingSize, iconOnly, unifiedSize, extendedSize), unifiedSize ? ButtonType.UnifiedFontSizes[unifiedSize] : '', 'focus-visible:ring-2', - dropdownItems && dropdownItems.length > 0 ? 'rounded-l-md' : 'rounded-md', + hasDropdown ? 'rounded-l-md' : 'rounded-md', 'justify-center items-center text-center inline-flex gap-2', 'active:opacity-80 transition-[background-color,opacity] duration-150', disabled @@ -345,7 +347,7 @@
0 ? dividerClass : '', + hasDropdown ? dividerClass : '', 'shrink-0', wrapperClasses, 'flex flex-row rounded-md', @@ -385,6 +387,7 @@ class={buttonClass} {id} {target} + {title} tabindex={disabled ? -1 : 0} {...rest} {style} @@ -484,7 +487,7 @@ {/if} {/if} - {#if dropdownItems && dropdownItems.length > 0} + {#if hasDropdown && dropdownItems} = { '2xs': 'px-1', // Compact horizontal padding xs: 'px-2', - sm: 'px-2', // Regular horizontal padding + // One step of its own rather than sharing xs's: sm is the size a page's header buttons are, + // where a label needs room to read as a button rather than as text with a box around it. + sm: 'px-3', md: 'px-4', lg: 'px-6' } diff --git a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte index f3870b3bb6..ae2c4c4bae 100644 --- a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte +++ b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte @@ -50,6 +50,9 @@ /** Render after the right-pinned tabs, outside the scroll area so it stays * pinned (e.g. a "Split with Preview" toggle). */ trailing?: import('svelte').Snippet + /** Tab height. `sm` (the default) is the 24px pill the panel strips use; `md` is 28px, for a + * strip that fills a 44px bar, where the smaller pill leaves too much air around it. */ + size?: 'sm' | 'md' /** Render inside each tab (after the label; receives the tab + isActive). Clicks * bubble to the tab unless the accessory stops them. The tab is position:relative * so an `absolute inset-0 pointer-events-none` child can anchor a whole-tab @@ -65,6 +68,7 @@ onReorder, onActiveClick, class: c = '', + size = 'sm', afterTabs, trailing, tabAccessory @@ -180,7 +184,8 @@ function tabClasses(isActive: boolean) { return twMerge( - 'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + 'group relative inline-flex items-center gap-1.5 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + size === 'md' ? 'h-7 px-3 gap-2' : 'h-6 px-2.5', isActive ? 'bg-surface-tertiary text-emphasis' : 'bg-transparent text-hint hover:text-secondary' diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte index 1300dc4322..86f04dfa9f 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte @@ -70,7 +70,14 @@
- + +
diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 1a1412b9f8..3e0351f249 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -3,14 +3,12 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import SessionsBetaBanner from '$lib/components/sessions/SessionsBetaBanner.svelte' import { zIndexes } from '$lib/zIndexes' - import { userStore, workspaceStore } from '$lib/stores' + import { workspaceStore } from '$lib/stores' import { chatState } from './sharedChatState.svelte' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { copilotInfo } from '$lib/aiStore' import { aiChatManager } from './AIChatManager.svelte' import { onDestroy } from 'svelte' - import Button from '$lib/components/common/button/Button.svelte' - import { Menu } from 'lucide-svelte' import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte' interface Props { @@ -20,7 +18,6 @@ transitionClass?: string isMobile?: boolean children: any - onMenuOpen?: () => void disableAi?: boolean // Whether this layout loads the workspace AI config. It gates far more than // the docked pane (code completion, metadata generation, the "open in AI @@ -40,15 +37,14 @@ transitionClass = 'transition-all ease-in-out duration-200', isMobile = false, children, - onMenuOpen, disableAi, loadAiConfig = true, showSessionsBetaBanner = false }: Props = $props() // The desktop rail is fixed-positioned, so the content is offset by a matching - // left padding (in rem, matching the rail). Mobile/operator/borderless: no rail. - let contentPadLeft = $derived(noBorder || $userStore?.operator || isMobile ? 0 : sidebarWidth) + // left padding (in rem, matching the rail). Drawer (mobile or detached)/borderless: no rail. + let contentPadLeft = $derived(noBorder || isMobile ? 0 : sidebarWidth) $effect(() => { chatState.dockedChatAvailable = !disableAi @@ -83,23 +79,6 @@ }) -{#snippet burgerRow()} -
-
-{/snippet} - {#if !disableAi} @@ -111,7 +90,6 @@ >
- {@render burgerRow()}
{@render children?.()}
@@ -138,12 +116,14 @@ {/if} {:else} +
- {@render burgerRow()}
{@render children?.()}
diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 23a80c4026..3e3baa36d7 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -2719,7 +2719,7 @@ function allowedTriggerKinds(): PageTriggerKind[] { } // Pages an operator may reach: exactly the ones enabled in the workspace's -// operator_settings, the same source OperatorMenu gates on. Operators are never admins, so +// operator_settings, the same source the sidebar gates on. Operators are never admins, so // workspace_settings is excluded whatever the settings say. function operatorOpenPages(workspaceId: string | undefined): OpenPageName[] { const settings = get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 72c39ba512..34a7fab355 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -3,10 +3,10 @@ import DropdownV2 from '$lib/components/DropdownV2.svelte' import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte' - import { twMerge } from 'tailwind-merge' - import { userStore } from '$lib/stores' - import { createEventDispatcher, getContext, tick } from 'svelte' + import { createEventDispatcher, getContext, tick, type Snippet } from 'svelte' import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import type { TriggerContext } from '../triggers' import type { Item } from '$lib/utils' import { Bell, BellOff, Calendar } from 'lucide-svelte' @@ -37,9 +37,10 @@ } // An agent's page has no triggers, and so no context for them. - const { triggersCount, triggersState } = $state( - getContext('TriggerContext') ?? ({} as Partial) - ) + const triggerContext = getContext('TriggerContext') + const { triggersCount, triggersState } = $state(triggerContext ?? ({} as Partial)) + // The buttons below render in the page header, away from this page's tree. + const headerContexts = new Map([['TriggerContext', triggerContext]]) interface Props { mainButtons?: MainButton[] @@ -49,6 +50,9 @@ tag?: string | undefined /** Unset for what has no workspace error handler to mute, such as an agent. */ errorHandlerKind?: 'flow' | 'script' + /** What the rename popover is renaming, when that is not the error handler's kind — an + * agent has no error handler but is still renamed from here. */ + itemKind?: 'flow' | 'script' | 'agent' scriptOrFlowPath?: string errorHandlerMuted?: boolean | undefined labels?: string[] | undefined @@ -56,6 +60,10 @@ onSaved?: (newPath: string) => void children?: import('svelte').Snippet trigger_badges?: import('svelte').Snippet + /** True for the route's own page: its header becomes the page header. A host that renders + * this inside something else — a session's preview panel — leaves it false and gets the + * row below. */ + ownsPageHeader?: boolean /** Whether the bar is wide enough to hold every button beside the summary, decided by * the layout from its own width. Not a viewport media query: the same page also renders * inside an AI session's preview panel, where the window is wide and the pane is not. */ @@ -71,6 +79,7 @@ path, tag, errorHandlerKind, + itemKind, scriptOrFlowPath, errorHandlerMuted = $bindable(), labels = $bindable(), @@ -78,13 +87,30 @@ onSaved, children, trigger_badges, + ownsPageHeader = false, wide = true, leading_actions }: Props = $props() const dispatch = createEventDispatcher() - const barButtons = $derived(wide ? mainButtons : mainButtons.filter((b) => !b.narrow)) + // A flow and a script are renamed as what their error handler already names them; an agent has + // no error handler and says so itself. + const renameKind = $derived(itemKind ?? errorHandlerKind) + + // Two reasons the row can be too tight, and either one collapses it. The host knows when its + // own box is narrow while the window is not — a session's preview panel — and says so through + // `wide`. The band knows its own width, which the host cannot see: these buttons share that row + // with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured + // (0) counts as wide, because the bar measures itself on mount and starting narrow would pop + // the buttons out of the menu a frame later. + // Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row + // has to fold before that rather than at the ~1150 the two parts add up to on paper. + const COLLAPSE_BELOW = 1250 + const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW) + const wideRow = $derived(wide && roomInBar) + + const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow)) function dropdownHost(btn: MainButton): MainButton | undefined { if (typeof btn.narrow !== 'object') return undefined @@ -104,7 +130,7 @@ } const allMenuItems: Item[] = $derived([ - ...(wide ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({ + ...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({ displayName: b.label, description: b.description, icon: b.buttonProps.startIcon, @@ -113,7 +139,7 @@ disabled: b.buttonProps.disabled, type: 'action' as const })), - ...(wide || !errorHandlerKind + ...(wideRow || !errorHandlerKind ? [] : [ { @@ -129,12 +155,12 @@ action: item.onclick, disabled: item.disabled, type: item.color === 'red' ? ('delete' as const) : ('action' as const), - separatorTop: i === 0 && !wide + separatorTop: i === 0 && !wideRow })) ]) function dropdownItemsOf(host: MainButton) { - if (wide) return undefined + if (wideRow) return undefined const items = mainButtons .filter((b) => dropdownHost(b) === host) .map((b) => ({ @@ -149,74 +175,138 @@ } -
-
-
+{/snippet} + + +{#snippet pathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + + +{#snippet badges(size: 'sm' | 'md')} + {#if tag} + tag: {tag} + {/if} + {@render children?.()} + {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} + {@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)} + + {/if} + {@render trigger_badges?.()} +{/snippet} + +{#snippet controls(size: 'sm' | 'md')} + {@render leading_actions?.()} + {#if allMenuItems.length > 0} + {#key allMenuItems} + + {/key} + {/if} + {#if wideRow && errorHandlerKind && scriptOrFlowPath} + + {/if} + {#each barButtons as btn (btn.label)} + {@const dropdownItems = dropdownItemsOf(btn)} + + {/each} +{/snippet} + +{#snippet actions()} + {@render badges('sm')} + {@render controls('sm')} +{/snippet} + +{#if ownsPageHeader} + + +{:else} + +
+
+
+
+
+ +
+ {@render badges('md')} +
+
+ {@render controls('md')}
- {#if tag} - tag: {tag} - {/if} - {@render children?.()} - {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} - {@const primarySchedule = triggersState.triggers.findIndex( - (t) => t.isPrimary && !t.isDraft - )} - - {/if} - {@render trigger_badges?.()} -
-
- {@render leading_actions?.()} - {#if allMenuItems.length > 0} - {#key allMenuItems} - - {/key} - {/if} - {#if wide && errorHandlerKind && scriptOrFlowPath} - - {/if} - {#each barButtons as btn (btn.label)} - {@const dropdownItems = dropdownItemsOf(btn)} - - {/each}
-
+{/if} diff --git a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte index 49622c8af2..48e833e2ff 100644 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte +++ b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte @@ -13,9 +13,16 @@ scriptOrFlowPath: string errorHandlerMuted: boolean | undefined iconOnly?: boolean + unifiedSize?: 'xs' | 'sm' | 'md' | 'lg' } - let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props() + let { + kind, + scriptOrFlowPath, + errorHandlerMuted = $bindable(), + iconOnly = true, + unifiedSize = 'md' + }: Props = $props() async function toggleErrorHandler(): Promise { const next = await toggleWorkspaceErrorHandler( @@ -32,7 +39,7 @@ title={errorHandlerMuted === undefined || !errorHandlerMuted ? 'Disable workspace error handler for this script' : 'Enable workspace error handler for this script'} - unifiedSize="md" + {unifiedSize} on:click={toggleErrorHandler} variant="subtle" startIcon={{ diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 0e9f47816b..ce7378ede4 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -656,6 +656,7 @@ {#snippet header({ wide }: { wide: boolean })} { detailLayout?.showTriggers() }} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 078ab20a47..f33f00fb8f 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -838,6 +838,7 @@ {#snippet header({ wide }: { wide: boolean })} - import { - ChevronRight, - FlaskConical, - Settings, - FormInput, - History, - MessageSquare, - Save - } from 'lucide-svelte' + import { FlaskConical, Settings, FormInput, History, MessageSquare, Save } from 'lucide-svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { onDestroy, untrack } from 'svelte' + import { onDestroy, untrack, type Snippet } from 'svelte' import { resource } from 'runed' import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte' import PagedContent from '$lib/components/common/modal/PagedContent.svelte' @@ -39,6 +31,10 @@ import AgentSettings from './AgentSettings.svelte' import AgentEvalsModal from './AgentEvalsModal.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import PathEditPopover from '$lib/components/PathEditPopover.svelte' + import { emptyString } from '$lib/utils' + import Tooltip from '$lib/components/Tooltip.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -93,6 +89,9 @@ let host = $state | undefined>(undefined) let versionDrawer: Drawer | undefined = $state(undefined) let settingsDrawer: Drawer | undefined = $state(undefined) + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state(undefined) let evalsModal: AgentEvalsModal | undefined = $state(undefined) let saving = $state(false) @@ -136,9 +135,10 @@ let canEvaluate = $derived( draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused ) - // A never-deployed agent is stored at a minted `draft_` path, so it is named by the path - // its first deploy will create, as the home list names it. - let shownPath = $derived((draftOnly && draft?.state?.path) || target?.path) + // The draft's path, so a rename shows where it is typed — including a never-deployed agent, + // stored at a minted `draft_` path and named by the path its first deploy will create, + // as the home list names it. Falls back to the target while the draft is still loading. + let shownPath = $derived(draft?.state?.path || target?.path) // Where the evals pane is within itself, so its levels extend this dialog's trail rather than // opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on @@ -312,34 +312,22 @@ {@render body()} {:else} + +
-
-
-
- {#each trail as segment, i (i)} - {#if i > 0} - - {/if} - {#if segment.onclick} - - {:else} - {segment.label} - {/if} - {#if i === 0} - {@render titleBadge()} - {/if} - {/each} - {@render levelBadge()} -
- {#if description} - {description} - {/if} -
-
- {@render settings()} -
{@render body()}
@@ -347,6 +335,95 @@ {/if} {/key} + + {#snippet agentPathTrigger(pathLabel: Snippet, triggerClass: string)} + + {#if draft?.state} + draft.state?.description ?? '', + (v) => { + if (draft.state) draft.state.description = v + } + } + bind:path={ + () => draft.state?.path ?? '', + (v) => { + if (draft.state) draft.state.path = v + } + } + bind:snapshotPath={pathSnapshot} + bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + savedPath={draft?.noDeployed ? undefined : target?.path} + kind="resource" + workspaceId={ws} + pathEditable={!readOnly} + summaryEditable={!readOnly} + /> + {:else} + {@render pathLabel()} + {/if} + {/snippet} + + {#snippet agentSummary()} + +
+ {#if draft?.state} + {#snippet summaryText()} + {emptyString(draft.state?.description) + ? 'Add a summary...' + : draft.state?.description} + {/snippet} + draft.state?.description ?? '', + (v) => { + if (draft.state) draft.state.description = v + } + } + bind:path={ + () => draft.state?.path ?? '', + (v) => { + if (draft.state) draft.state.path = v + } + } + bind:snapshotPath={pathSnapshot} + bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + savedPath={draft?.noDeployed ? undefined : target?.path} + kind="resource" + workspaceId={ws} + pathEditable={!readOnly} + summaryEditable={!readOnly} + penVisibility={emptyString(draft.state.description) ? 'always' : 'hover'} + /> + {/if} + {@render titleBadge()} +
+ {/snippet} + {#snippet agentHint()} + {#if description} + {description} + {/if} + {/snippet} {#snippet titleBadge()} diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index a8e4d8b973..62479b70d1 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -46,10 +46,20 @@ import HomeConnectDrawer from './HomeConnectDrawer.svelte' import { USER_SETTINGS_HASH } from '../sidebar/settings' import { prefersSessionHandoff } from '../copilot/chat/global/gate' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { onboardingProfile } from '$lib/onboardingProfile' const COLLAPSED_SETTING = 'home-ai-composer-collapsed' + /** Bar width from which the two side trips stand in the bar rather than folding into the menu. + * Home's trail is ~250px and the rest of its actions ~400, so this leaves room for both + * labelled buttons without the trail starting to truncate. Below it they fold back. */ + const BAR_FITS_SIDE_TRIPS = 1024 + const sideTripsInBar = $derived(pageHeader.barWidth >= BAR_FITS_SIDE_TRIPS) + const hubOffered = $derived(!$userStore?.operator && HOME_SHOW_HUB) + let value = $state('') // The stock examples, unless the invite that brought this person here wrote prompts for // them — those replace the set outright rather than joining it, since a prompt written @@ -73,6 +83,26 @@ storeLocalSetting(COLLAPSED_SETTING, next ? 'true' : undefined) } + // Removing puts the composer away rather than throwing it out, and the way back is one item in + // the band's menu — so the ellipsis flashes as it appears, in a corner nobody is watching while + // the hero collapses in the middle of the page. + // + // A keyframe animation, not a transition: this mounts in the same flush that collapses the + // hero, so the frame that would paint it at rest is the frame it is already told to leave, and + // one `requestAnimationFrame` is not reliably past that paint. The flash then goes straight to + // invisible without ever being drawn. Mounted only while pulsing, so each removal replays it. + const PULSE_MS = 1000 + let pulsing = $state(false) + let pulseEndTimer: ReturnType | undefined + + function removeComposer() { + setCollapsed(true) + if (reducedMotion.val) return + clearTimeout(pulseEndTimer) + pulsing = true + pulseEndTimer = setTimeout(() => (pulsing = false), PULSE_MS) + } + // In global-AI mode the layout's chat panel is disabled and never loads the copilot // config, so the home chat loads it for the current workspace itself. $effect(() => { @@ -107,8 +137,9 @@ // unlocked, so the far commoner unlocked workspace never pops the composer in mid-load. let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment')) - // The composer hands off to /sessions, which refuses operators — so hide it from them (the - // prompt would be silently dropped) while the AI-independent CLI/MCP row below stays. + // The composer hands off to /sessions, which refuses operators and users opted out of the + // sessions beta — so hide it from them (the prompt would be silently dropped) while the + // AI-independent CLI/MCP row below stays. let showComposer = $derived( prefersSessionHandoff($userStore?.operator) && !runOnlyWorkspace && @@ -116,7 +147,7 @@ ) // The hero's margins and centered column are for the full block. The lone button row left - // by a collapsed, operator, run-only or hidden-assistant view is a hint line and should + // by a collapsed, opted-out, run-only or hidden-assistant view is a hint line and should // cost the page almost nothing: no top margin, and the content column's full width so it // hugs the right edge instead of floating centered in empty space. let hero = $derived(showComposer && !collapsed) @@ -149,7 +180,7 @@ const FADE_MS = 600 // Rotate the example prompt every CYCLE_MS: fade the placeholder out, swap it, fade it back in. - // Only while the composer is shown — otherwise (operators) it would loop forever driving an + // Only while the composer is shown — otherwise it would loop forever driving an // unrendered input — and not under reduced motion, where the first prompt simply stays put. // The index lives outside the effect so re-showing the composer resumes the rotation from the // prompt currently displayed rather than restarting it. @@ -185,7 +216,7 @@
- setCollapsed(true)} /> +
{/if}
@@ -272,9 +303,7 @@ {freeTierExhausted ? 'Add your own API key' : 'Configure AI'} {/if} - +
{/if} @@ -295,49 +324,106 @@ {/each}
- {:else if showComposer} - - - {:else} -
{/if} - - -
- - {#if !$userStore?.operator && HOME_SHOW_HUB} - - {/if} -
+ + + + + +{#snippet homeMenu()} + + + + {#if sideTripsInBar} + + + {#if hubOffered} + + {/if} + {/if} + + + {#if !sideTripsInBar || (showComposer && collapsed)} + + + {#if pulsing} + + + {/if} + setCollapsed(false) + } + ] + : []), + ...(sideTripsInBar + ? [] + : [ + { + displayName: 'CLI / MCP', + icon: PlugZap, + action: () => homeConnectDrawer?.openDrawer?.() + }, + ...(hubOffered + ? [ + { + displayName: 'Hub', + icon: Globe2, + href: $hubBaseUrlStore, + hrefTarget: '_blank' as const + } + ] + : []) + ]) + ]} + /> + + {/if} + +{/snippet} diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index ab6de64123..02ed1d90f7 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -16,6 +16,10 @@ interface Props { placement?: Placement + /** Inline style on the panel. A margin here shifts it after floating-ui has placed it, + * which is the only way to anchor it to a box wider than the trigger: melt's menubar + * menu ignores `positioning.offset`. */ + contentStyle?: string justifyEnd?: boolean lightMode?: boolean maxHeight?: number @@ -45,6 +49,7 @@ let { placement = 'right-start', + contentStyle = '', justifyEnd = false, lightMode = false, maxHeight = 900, @@ -221,6 +226,7 @@ onmouseenter={cancelPendingClose} onmouseleave={scheduleClose} transition:placementFly={{ duration: 100, placement }} + style={contentStyle} class={twMerge( 'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none', // Default: scroll on the melt element. submenuSafe moves it to the inner diff --git a/frontend/src/lib/components/moveRenameManager.ts b/frontend/src/lib/components/moveRenameManager.ts index 1500f8fc69..e5844a3351 100644 --- a/frontend/src/lib/components/moveRenameManager.ts +++ b/frontend/src/lib/components/moveRenameManager.ts @@ -1,7 +1,8 @@ -import { AppService, FlowService, ScriptService } from '$lib/gen' +import { AppService, FlowService, ResourceService, ScriptService } from '$lib/gen' +import { agentEditorRefusal } from './flows/agentResourceUtils' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' -type ItemKind = 'flow' | 'script' | 'app' +type ItemKind = 'flow' | 'script' | 'app' | 'agent' /** * Check whether a flow uses on_behalf_of_email. @@ -71,6 +72,24 @@ export async function updateItemPathAndSummary(opts: { skip_draft_deletion: true } }) + } else if (kind === 'agent') { + // An agent is a resource, and its summary is the resource's description. The read before + // the write is the same one the agent editor does: an update carries no resource type, so + // a path deleted and recreated as something else in the meantime would otherwise take an + // agent's config. + const current = await ResourceService.getResource({ workspace, path: initialPath }) + const refused = agentEditorRefusal(initialPath, current.resource_type) + if (refused) { + throw new Error(refused) + } + // Only what this popover edits: the backend sets a column per field it is given, so the + // agent's own config, its type and `ws_specific` are left where they are rather than read + // here and written back over whatever changed in between. + await ResourceService.updateResource({ + workspace, + path: initialPath, + requestBody: { path: newPath, description: newSummary, labels } + }) } else if (kind === 'app') { await AppService.updateApp({ workspace, diff --git a/frontend/src/lib/components/operatingWorkspace.svelte.ts b/frontend/src/lib/components/operatingWorkspace.svelte.ts index 93a7f2c0de..70f1c63c1c 100644 --- a/frontend/src/lib/components/operatingWorkspace.svelte.ts +++ b/frontend/src/lib/components/operatingWorkspace.svelte.ts @@ -22,6 +22,14 @@ export function setOperatingWorkspace(resolve: () => string | undefined): void { setContext(KEY, outer ? () => resolve() ?? outer() : resolve) } +/** This subtree's operating workspace as an entry for a `ContextBridge` contexts map. A snippet + * handed to the page header's band renders outside the subtree that declared the workspace, so + * without this it would quietly act on the navigation workspace instead. Reads context: call + * during component initialisation. */ +export function operatingWorkspaceContextEntry(): [any, any] { + return [KEY, getContext(KEY)] +} + /** The workspace this component acts on, as a store. Reads context: call during component * initialisation. Falls back to the navigation workspace where no host set one, or where the * host's resolves to nothing. */ diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts new file mode 100644 index 0000000000..3167d636e9 --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -0,0 +1,185 @@ +import type { Snippet } from 'svelte' +import type { WorkspaceItemKind } from './workspacePicker' + +/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */ +export type PageHeaderItem = { + /** Absent for an item the picker does not file under a kind — an agent's detail page. The + * breadcrumb then names the item without claiming it is one of the three. */ + kind?: WorkspaceItemKind + /** Shown whole. Clicking it opens whatever the page hangs off it with `pathTrigger`, and copies + * the path where there is none. */ + path: string | undefined + /** Wraps the band's own path segment in whatever the page wants behind it — its rename + * popover, anchored there rather than beside the summary, so the editor opens under the half + * of the name that was clicked. The band passes the path as it renders it and the classes its + * own segment wears, so the page's control keeps the breadcrumb's look rather than inventing + * one. Without this the segment copies the path on click. */ + pathTrigger?: Snippet<[Snippet, string]> + summary?: string + /** Rendered in place of the plain summary, for a page whose summary is itself a control — + * a detail page's rename-and-labels popover, an editor's editable title. */ + summaryContent?: Snippet +} + +/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */ +export type PageHeaderSection = { + label: string + /** Rendered in place of `label` when the name is itself a control, such as an editable title. */ + content?: Snippet + /** Scopes the segment's picker, when the section maps to a kind of item. */ + kind?: WorkspaceItemKind +} + +export type PageHeaderContent = { + item?: PageHeaderItem + section?: PageHeaderSection + /** Rendered at the right end of the bar — the page's own buttons. */ + actions?: Snippet + /** Orders these buttons among the other registrations', ascending; registration order breaks a + * tie. For a menu that must stay at the far end however many pages register before it. */ + actionsOrder?: number + /** These actions can give width back under pressure, so the bar lets their box shrink on a + * phone. Two ways to earn it: a control that is narrower when it has to be (the Runs filter + * row, whose search field yields), or a set that scrolls inside its own box (the app and + * pipeline editors, whose rows are too wide for any bar). Without it the box holds its content + * width, because squeezing a plain row of buttons only pushes them out of the bar. */ + actionsFlexible?: boolean + /** These actions are not a row of buttons at the far end but a surface of their own, running + * from the breadcrumb to the bar's right edge: a session in full screen, whose tab strip and + * controls share one shape that opens into the page below. The bar gives up its right padding + * to it, since the surface is what meets the edge. */ + actionsFill?: boolean + /** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name, + * like a documentation tooltip, or a control that acts on the thing named — an app's Edit, + * which at the far end of the bar would be a journey away from what it edits. */ + afterName?: Snippet + /** The workspace this page acts on, when it is not the one the app is pointed at — a session + * running in a fork. The breadcrumb then names that workspace, and its fork part shows only + * when it really is a fork. */ + actingWorkspaceId?: string + /** Width in px, at the right of the viewport, that this page owns from the top down — a + * session's side panel. The band stops there instead of running over it, and the band floats + * above the page rather than pushing it down, so that column starts at the top. */ + barRightInset?: number + /** The bottom edge under the bar, which the bar owns so a page cannot draw a second one. + * `onScroll` (the default) shows it only while something is passing under the bar — at the top + * of a list there is nothing to divide. `always` is for a page whose content starts right + * under the bar and needs the division at rest: an editor, a detail page, a settings pane. + * `none` is for a page that genuinely draws its own first line, such as the script editor's + * toolbar or the flow editor's canvas. */ + separator?: 'onScroll' | 'always' | 'none' + /** Contexts this registration's snippets look up — its actions, what it hangs after the name, + * and its summary. They all render under the header rather than under the page that wrote + * them, so anything the page's tree provides has to travel with them. */ + contexts?: Map +} + +// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb +// and actions by registering here. +// +// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or +// summary the page edits reaches the bar through the layout's own dependency tracking, with no +// effect keeping two copies in step. +// +// Registrations stack, and what the bar shows is their merge (see `content`). Pages register on +// mount, so the first entry is the outermost one — an editor nested in a session pane or a drawer +// is asked to register nothing. +let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([]) +let nextId = 0 + +/** + * Below this bar width a page keeps one button — the thing the page is for — and folds the rest + * into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet + * in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the + * line sits above the tablet rather than between the two. + */ +export const PHONE_BAR = 800 + +/** The bar's height, and the padding a page owes the band when the band floats over it rather than + * sitting in the flow (see `barRightInset`). They are one measurement in two forms: raise one and + * the other has to follow, or the page's own content disappears under the bar. Written as literal + * class names so Tailwind sees them. */ +export const BAR_HEIGHT = 'h-12' +export const BAR_TOP_PAD = 'pt-12' +/** The same height in px, for the places that animate to the bar's bottom edge rather than + * standing under it — a transition's `css` and a measurement's fallback take a number. */ +export const BAR_HEIGHT_PX = 48 + +// The bar's own width, written by the bar and read by the pages that fill it. A page's buttons +// share that row with the breadcrumb, so it is the width they crowd against — not the window's, +// which the sidebar and a page's side panel both take from. +let barWidth = $state(0) + +/** + * One registration's content, or undefined while it cannot answer. + * + * An entry is a getter closing over another component's props, evaluated here, in a tree of its + * own. Nothing orders a parent clearing those props against the child's teardown: a route that + * drops the `{#if}` around an editor and the value it binds into it does both in one flush, and + * this derived re-runs on the same flush, before the editor's own cleanup releases its entry. The + * entry has nothing to say in that window, which is not the same as the page having no header — + * so it is skipped, and whatever registered before it still stands. + */ +function read(e: { get: () => PageHeaderContent }): PageHeaderContent | undefined { + try { + return e.get() + } catch { + return undefined + } +} + +export const pageHeader = { + /** + * The registrations merged, later ones winning field by field: a route sets the frame (where + * the bar goes) and something nested in it — the active session, say — fills the breadcrumb + * and the actions. + */ + get content(): PageHeaderContent | undefined { + if (entries.length === 0) return undefined + const merged: PageHeaderContent = {} + for (const e of entries) { + const c = read(e) + if (!c) continue + if (c.item !== undefined) merged.item = c.item + if (c.section !== undefined) merged.section = c.section + if (c.afterName !== undefined) merged.afterName = c.afterName + if (c.contexts !== undefined) merged.contexts = c.contexts + if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId + if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset + if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible + if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill + if (c.separator !== undefined) merged.separator = c.separator + } + return merged + }, + /** + * Every registration's actions, in registration order: a page and something inside it can both + * put buttons in the band, and each renders with the contexts its own registration named. + */ + get actions(): { render: Snippet; contexts?: Map }[] { + return entries + .flatMap((e, i) => { + const c = read(e) + return c?.actions + ? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }] + : [] + }) + .sort((a, b) => a.order - b.order || a.i - b.i) + }, + /** Width of the bar in px, or 0 before it has been measured. */ + get barWidth(): number { + return barWidth + }, + setBarWidth(w: number) { + barWidth = w + }, + /** Registers on mount and returns the id to release on destroy. */ + register(get: () => PageHeaderContent): number { + const id = nextId++ + entries = [...entries, { id, get }] + return id + }, + release(id: number) { + entries = entries.filter((e) => e.id !== id) + } +} diff --git a/frontend/src/lib/components/pageHeaderRegistry.test.ts b/frontend/src/lib/components/pageHeaderRegistry.test.ts new file mode 100644 index 0000000000..035eabb60e --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, afterEach } from 'vitest' +import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte' +import type { Snippet } from 'svelte' + +// The merge rule every registration site depends on: a page sets the frame and something nested +// in it fills the breadcrumb, so whether a component registers at all decides whose name the band +// shows. A viewer that registered wherever it was mounted renamed the band of the session hosting +// it. +const snippet = (name: string) => name as unknown as Snippet + +const ids: number[] = [] +function register(c: PageHeaderContent) { + ids.push(pageHeader.register(() => c)) +} +afterEach(() => { + while (ids.length) pageHeader.release(ids.pop()!) +}) + +describe('pageHeader', () => { + it('is empty with nothing registered', () => { + expect(pageHeader.content).toBeUndefined() + }) + + it('lets a later registration win field by field', () => { + register({ section: { label: 'Sessions' }, barRightInset: 400 }) + register({ item: { path: 'u/admin/app' } }) + expect(pageHeader.content).toEqual({ + section: { label: 'Sessions' }, + item: { path: 'u/admin/app' }, + barRightInset: 400 + }) + }) + + it('gives the fields of a released registration back', () => { + register({ section: { label: 'Sessions' } }) + register({ item: { path: 'u/admin/app' } }) + pageHeader.release(ids.pop()!) + expect(pageHeader.content?.item).toBeUndefined() + expect(pageHeader.content?.section).toEqual({ label: 'Sessions' }) + }) + + it('orders actions by actionsOrder, then by registration', () => { + register({ actions: snippet('page'), actionsOrder: 10 }) + register({ actions: snippet('nested') }) + register({ actions: snippet('alsoNested') }) + expect(pageHeader.actions.map((a) => a.render)).toEqual(['nested', 'alsoNested', 'page']) + }) + + it('carries the contexts of each registration with its actions', () => { + const pageContexts = new Map([['TriggerContext', 1]]) + register({ actions: snippet('page'), contexts: pageContexts }) + register({ actions: snippet('nested') }) + expect(pageHeader.actions.map((a) => a.contexts)).toEqual([pageContexts, undefined]) + }) +}) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 2288403438..d0d1b40de1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -154,6 +154,8 @@ * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ pendingDraftPath?: string | undefined // Threaded to the AutosaveIndicator's "Reset to deployed" button. + /** `Exit & see details`: see RawAppEditorHeader. */ + onDetails?: (e: { path: string }) => void onResetToDeployed?: () => void | Promise /** The app_version the draft forked from, for the deploy-time "new version * deployed" guard: deploying is refused with a confirmation while it is not @@ -198,6 +200,8 @@ // EditorHeader's path/breadcrumb row dropped (summary only). Used by the // session preview to save vertical room. condensedHeader?: boolean + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } let { @@ -221,6 +225,7 @@ autosavePath = undefined, defaultSplitWithPreview = true, pendingDraftPath = $bindable(undefined), + onDetails, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -237,6 +242,7 @@ onRestore, onSavedNewAppPath, condensedHeader = false, + ownsPageHeader = false, version = undefined, onTakeLatest = undefined, draftBaseVersion = undefined @@ -2564,6 +2570,7 @@ addEventListener('message', function (e) { {getBundle} {onNavigate} {onDeploy} + {onDetails} {onResetToDeployed} {loadedFromDraft} {othersDraftsCount} @@ -2576,6 +2583,7 @@ addEventListener('message', function (e) { sidebarCollapsed={sidebarCollapsed.val} onToggleSidebar={() => (sidebarCollapsed.val = !sidebarCollapsed.val)} {condensedHeader} + {ownsPageHeader} /> void liveEditorDraftStoragePath?: string /** Indicator-only overrides for the sessions preview: the AutosaveIndicator @@ -180,6 +186,9 @@ pendingDraftPath?: string | undefined // Threaded to the `AutosaveIndicator` popover so its "Reset to // deployed" button can do the same thing the load-time toast offers. + /** `Exit & see details`: leaves the editor for the app's deployed page. A session hosts that + * page itself, so it flips the tab there rather than navigating out of the session. */ + onDetails?: (e: { path: string }) => void onResetToDeployed?: () => void | Promise // See ScriptBuilderProps — same semantics for the raw-app editor's // indicator. @@ -223,12 +232,14 @@ sidebarCollapsed = false, onToggleSidebar = undefined, condensedHeader = false, + ownsPageHeader = false, onNavigate = undefined, liveEditorDraftStoragePath = undefined, autosaveWorkspace = undefined, autosavePath = undefined, onDeploy = undefined, pendingDraftPath = $bindable(undefined), + onDetails, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -308,11 +319,34 @@ // Top-bar responsive collapse — container width, not viewport. let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + // In the page header the buttons share the row with the breadcrumb, so they collapse sooner: + // the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff, + // Jobs, Export, AI and Deploy — is ~560px. + const compactBelow = $derived(ownsPageHeader ? 1350 : 720) + const compactTopbar = $derived.by(() => { + const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth + return w > 0 && w < compactBelow + }) + // A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down — + // the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already + // in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always. + const phoneTopbar = $derived( + ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR + ) // Top-bar button size + bar height. Condensed (session preview) uses the // smallest well-supported unified size (`sm`) so the bar is thinner. - const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md') + // In the page header the buttons ride a 44px band, the same size the condensed session-pane + // header uses. + const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md') + + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state(undefined) + + // The actions render under the band, outside the subtree RawAppEditor declared the operating + // workspace in, so it has to travel with them: OpenInSessionButton asks who the acting user is. + const headerContexts = new Map([operatingWorkspaceContextEntry()]) function closeSaveDrawer() { saveDrawerOpen = false @@ -903,121 +937,212 @@ errorByComponent={{}} /> -
- + ` placeholder, and the trail would name that instead of the path Deploy + // will create — which is why `newEditedPath` refuses the placeholder. Frozen while the + // rename popover is open so the trail holds still as the user types. + kind: 'app', + path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined), + summaryContent: appSummary, + pathTrigger: rawAppPathTrigger + }} + actions={rawAppHeaderActions} + contexts={headerContexts} + separator="none" + /> +{:else} +
+ -
- {#if onToggleSidebar} -
+{/if} + +{#snippet sidebarToggle()} + {#if onToggleSidebar} + - {/snippet} - +{/snippet} - +{#snippet rawAppPathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + +{#snippet appSummary()} + +
+ {#snippet summaryText()} + {emptyString(summary) ? 'Add a summary...' : summary} + {/snippet} + +
+{/snippet} + +{#snippet rawAppHeaderActions()} + + {@render sidebarToggle()} + {@render autosaveIndicator()} + {@render awarenessMark()} + {@render rawAppActions()} +{/snippet} + +{#snippet rawAppActions()} + + {#snippet buttonReplacement()} + + {/snippet} + + + -
+ -
+ Diff + +
-
- -
- +
({jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})
+
+ +
+ + + {#if !phoneTopbar} {#snippet fallback()} -
-
+ } + ] + : []) + ] + : undefined} + > + Deploy + +{/snippet} diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 557b2a13ad..8a79b4b8f9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -277,6 +277,6 @@ src={iframeSrc} sandbox={sandboxAttr} referrerpolicy={unsandboxed ? undefined : 'no-referrer'} - class="w-full h-full min-h-screen bg-white border-none" + class="w-full h-full bg-white border-none" > {/if} diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index ade1d62d47..3070572785 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -294,6 +294,9 @@ export function unsandboxedRawAppHtml( App + + -{#snippet externalLinkHint()} - -{/snippet} - {#if !session || !runtime}
Session not found
{:else} @@ -411,95 +376,81 @@ sessions have their own empty-state affordances above. --> {#snippet sessionEmptyHint()}{/snippet} + {#snippet sessionHeader()} + renameSession(session.id, v)} + class="text-xs font-normal" + inputClass="!text-xs !font-normal" + /> + + summaryInput?.edit() + }, + ...(session.archived + ? // No Unarchive when the workspace is gone — it can't persist + // (putSession guard) and reconcile would re-archive it. + isUnavailable + ? [] + : [ + { + displayName: 'Unarchive', + icon: ArchiveRestore, + action: () => setSessionArchived(session.id, false) + } + ] + : [ + { + displayName: 'Archive', + icon: Archive, + action: () => archiveAndReset() + } + ]), + { + displayName: 'Delete', + icon: Trash2, + type: 'delete', + action: () => (deleteConfirmOpen = true) + } + ]} + > + {#snippet buttonReplacement()} + + + + {/snippet} + + {/snippet} + + {#if headerInPage} + + + {/if} +
-
- renameSession(session.id, v)} - class="text-sm font-semibold" - inputClass="!text-sm !font-semibold" - /> - summaryInput?.edit() - }, - ...(session.archived - ? // No Unarchive when the workspace is gone — it can't persist - // (putSession guard) and reconcile would re-archive it. - isUnavailable - ? [] - : [ - { - displayName: 'Unarchive', - icon: ArchiveRestore, - action: () => setSessionArchived(session.id, false) - } - ] - : [ - { - displayName: 'Archive', - icon: Archive, - action: () => archiveAndReset() - } - ]), - { - displayName: 'Delete', - icon: Trash2, - type: 'delete', - action: () => (deleteConfirmOpen = true) - } - ]} - > - {#snippet buttonReplacement()} - - - - {/snippet} - - {#if acting && hasFirstUserMessage} - -
- Acting on - - - - -
- {/if} -
{f.name} {#if f.is_dev_workspace} - {devBadgeText(f.dev_workspace_label)} + {/if} {#if isSelected(f.id)} diff --git a/frontend/src/lib/components/sessions/sessionMode.svelte.ts b/frontend/src/lib/components/sessions/sessionMode.svelte.ts index a2c20e08a8..294cca19fb 100644 --- a/frontend/src/lib/components/sessions/sessionMode.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMode.svelte.ts @@ -47,6 +47,30 @@ export function withMenuHidden(url: string, workspaceId?: string): string { } } +// Remembers `nomenubar` for the rest of this iframe's life. Navigating inside a preview drops +// the query param (client-side routing and full loads both), and the global nav must not pop +// back in. sessionStorage so it survives a reload within the frame; the top window never writes +// it, so an ordinary tab and the oauth callback still toggle normally. +export function rememberMenuHidden(url: URL): void { + if (typeof window === 'undefined' || window.self === window.top) return + if (url.searchParams.get('nomenubar') !== 'true') return + try { + sessionStorage.setItem('nomenubar_embedded', 'true') + } catch {} +} + +// Whether this page is shown without the workspace navigation. The layout drops the sidebar and +// the band's trail, so a page whose only control lives in the band has to carry it itself — see +// InWorkspaceAppViewer's Edit button. Read this rather than the raw query param: a preview tab +// that has navigated no longer carries it. +export function isMenuHidden(url: URL): boolean { + return ( + url.searchParams.get('nomenubar') === 'true' || + url.pathname.startsWith('/oauth/callback/') || + isSessionPreviewFrame() + ) +} + // True when this window is a sessions-preview iframe: embedded, with the `nomenubar` flag // the preview always sets and the logged layout stickies into sessionStorage. export function isSessionPreviewFrame(): boolean { diff --git a/frontend/src/lib/components/sidebar/MenuButton.svelte b/frontend/src/lib/components/sidebar/MenuButton.svelte index 3d0286a4af..e6f23361bf 100644 --- a/frontend/src/lib/components/sidebar/MenuButton.svelte +++ b/frontend/src/lib/components/sidebar/MenuButton.svelte @@ -54,6 +54,8 @@ // Classes for the label line only — `class` reaches the button, the label and the // sublabel alike, which is the wrong tool for colouring one line of the two. labelClass?: string | undefined + // Draw the colour disc at 20px rather than 26px, for denser rows such as the page header. + compact?: boolean } let { @@ -79,7 +81,8 @@ emphasizeLabel = false, disableTitle = false, ariaLabel = undefined, - labelClass = undefined + labelClass = undefined, + compact = false }: Props = $props() let buttonRef: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined) @@ -94,7 +97,7 @@ @@ -119,7 +122,8 @@ {href} data-light-mode={lightMode} class={twMerge( - 'group flex items-center px-2 py-2 font-light rounded-md gap-2 w-full', + 'group flex items-center px-2 py-2 font-light rounded-md gap-2', + compact ? 'w-auto' : 'w-full', sidebarClasses.hoverBg, 'transition-all relative', sublabel ? 'h-10' : 'h-8', @@ -134,11 +138,23 @@ {#if icon} {@const SvelteComponent = icon} {#if color} - - - + {@const disc = compact ? 20 : 26} + {@const glyph = compact ? 12 : 16} + + + @@ -158,7 +174,7 @@ {/if} {/if} -
+
{#if !isCollapsed && label}
- import { - Home, - Menu as MenuIcon, - Play, - Settings, - LogOut, - Moon, - Sun, - Code2, - LayoutDashboard, - Calendar, - ServerCog, - Table2, - GraduationCap - } from 'lucide-svelte' - import SwitchWorkspaceSubmenu from './SwitchWorkspaceSubmenu.svelte' - import { - OPERATOR_MAIN_LINKS, - OPERATOR_SECONDARY_LINKS, - OPERATOR_TRIGGER_LINKS, - type OperatorMenuLink, - type OperatorTriggerLink - } from './operatorRoutes' - import { base } from '$lib/base' - import { page } from '$app/state' - import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' - - import MultiplayerMenu from './MultiplayerMenu.svelte' - import { Plus } from 'lucide-svelte' - import { - enterpriseLicense, - superadmin, - usedTriggerKinds, - userWorkspaces, - workspaceColor, - workspaceStore - } from '$lib/stores' - import { twMerge } from 'tailwind-merge' - import { getContrastTextColor } from '$lib/utils' - import { USER_SETTINGS_HASH } from './settings' - import { logout } from '$lib/logoutKit' - import DarkModeObserver from '../DarkModeObserver.svelte' - import BarsStaggered from '../icons/BarsStaggered.svelte' - import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents' - import MenuButton, { sidebarClasses } from './MenuButton.svelte' - import MenuLink from './MenuLink.svelte' - import type { FavoriteKind } from './FavoriteMenu.svelte' - let darkMode: boolean = $state(false) - let showExtraTriggers = $state(false) - // The menu opens on hover, so this doubles as "the pointer is on the button": - // it slides the workspace name out beside the icon, and keeps it out once a - // click has pinned the menu open and the pointer has moved away. - let menuOpen = $state(false) - - interface Props { - isCollapsed?: boolean - favoriteLinks?: { - label: string - href: string - kind: FavoriteKind - }[] - } - - let { isCollapsed = false, favoriteLinks = [] }: Props = $props() - - const MAIN_LINK_ICONS: Record = { home: Home, runs: Play, schedules: Calendar } - let activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) - let workspaceName = $derived(activeWorkspace?.name ?? $workspaceStore ?? '') - // Home is where an operator lands and gets their bearings, so the name stays out there - // rather than waiting for a hover. Everywhere else the button is the page's own chrome - // and stays out of the way until asked. - let showWorkspaceName = $derived(menuOpen || page.url.pathname === `${base}/`) - // Most workspaces never get a colour (it is only ever set explicitly), and this button - // floats over page content with nothing else behind it — so the disc falls back to a - // neutral rather than vanishing, keeping the glyph on a ground of its own everywhere. - // A token rather than a hex so it follows the theme; getContrastTextColor only parses - // hex, so it returns undefined here and the glyph keeps its themed `text-hint`. - const NEUTRAL_DISC = 'rgb(var(--color-surface-sunken))' - - let mainMenuLinks = $derived( - OPERATOR_MAIN_LINKS.map((link) => ({ ...link, icon: MAIN_LINK_ICONS[link.id] })).filter( - (link) => link.id === 'home' || filterLink(link) - ) - ) - - function filterLink(link: OperatorMenuLink) { - if (!$userWorkspaces || !$workspaceStore) return false - return activeWorkspace?.operator_settings?.[link.id] === true - } - let secondMenuLinks: OperatorMenuLink[] = $derived(OPERATOR_SECONDARY_LINKS.filter(filterLink)) - let allTriggerLinks: OperatorTriggerLink[] = $derived(OPERATOR_TRIGGER_LINKS.filter(filterLink)) - let secondMenuTriggerLinks = $derived( - allTriggerLinks.filter((link) => $usedTriggerKinds.includes(link.kind)) - ) - let extraTriggerLinks = $derived( - allTriggerLinks.filter((link) => !$usedTriggerKinds.includes(link.kind)) - ) - - - - {#snippet children({ createMenu })} - (showExtraTriggers = false)} - > - {#snippet triggr({ trigger, pinned })} - {@const iconColor = getContrastTextColor($workspaceColor)} - -
- -
- {/snippet} - {#snippet children({ item, builders })} -
- {#each favoriteLinks ?? [] as favorite (favorite.href)} - - - {#if favorite.kind == 'script'} - - {:else if favorite.kind == 'flow'} - - {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} - - {:else if favorite.kind == 'asset'} - - {/if} - - - {favorite.label} - - - {/each} -
- - {#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {/each} - -
-
- - - Account settings - - - - - Take the tour - -
- -
- { - if (!document.documentElement.classList.contains('dark')) { - document.documentElement.classList.add('dark') - window.localStorage.setItem('dark-mode', 'dark') - } else { - document.documentElement.classList.remove('dark') - window.localStorage.setItem('dark-mode', 'light') - } - }} - lightMode - class={twMerge( - 'w-full flex gap-3.5 px-2 py-2', - 'transition-colors', - sidebarClasses.text, - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - {#if darkMode} - - {:else} - - {/if} - Switch theme - - - - {#if $superadmin} - - - Instance settings - - {/if} - - logout()} - class={twMerge( - 'flex flex-row gap-3.5 items-center px-2 py-2 w-full', - 'text-primary text-xs', - 'cursor-pointer', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - - Sign out - -
-
- {#snippet renderSecondMenuLinks(menuLinks: OperatorMenuLink[])} - {#each menuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {menuLink.label} - - {/each} - {/snippet} - {#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length} -
- {#if secondMenuLinks.length}
{@render renderSecondMenuLinks(secondMenuLinks)}
{/if} - {#if secondMenuTriggerLinks.length}
{@render renderSecondMenuLinks(secondMenuTriggerLinks)}
{/if} - {#if extraTriggerLinks.length}
- -
{ - // This row expands the list below it instead of acting on the selection, and - // melt keeps the menu open only for a click it sees as defaultPrevented. - // Svelte delegates onclick to the root, which runs after melt's own listener, - // and a capture listener on the item itself would be ordered only by - // registration, so an ancestor's capture phase is what reliably wins. - e.preventDefault() - showExtraTriggers = !showExtraTriggers - }} - > - - - More triggers - -
- {#if showExtraTriggers} - {#each extraTriggerLinks as menuLink (menuLink.href)} - - {menuLink.label} - - {/each} - {/if} -
{/if} -
- {/if} - {#if $enterpriseLicense} - - {/if} -
-
- {/snippet} -
- {/snippet} -
- - diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index e0951f37cd..fed773c84c 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -18,7 +18,8 @@ Crown, Gauge, Trash2, - KeyRound + KeyRound, + GraduationCap } from 'lucide-svelte' import { base } from '$app/paths' import { goto } from '$lib/navigation' @@ -38,6 +39,8 @@ import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings' import { accountSetup } from './accountSetup.svelte' import { EXECUTIONS_HINT } from './executionsHint' + import { sidebarPageAllowed } from './operatorRoutes' + import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import { userWorkspaces, workspaceStore, @@ -248,6 +251,15 @@ icon: Settings, action: () => goto(USER_SETTINGS_HASH) }, + ...($userStore?.operator + ? [ + { + displayName: 'Take the tour', + icon: GraduationCap, + action: () => goto(`${base}/?${TOUR_PARAM}=${TOUR_PARAM_VALUE}`) + } + ] + : []), ...(cloudHosted && $isPremiumStore === false ? [ { @@ -307,6 +319,7 @@ label="Workers" href="{base}/workers" icon={ServerCog} + disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')} {isCollapsed} aiId="sidebar-menu-link-workers" aiDescription="Button to navigate to workers" @@ -352,7 +365,7 @@ label="Audit logs" href="{base}/audit_logs" icon={Eye} - disabled={$userStore?.operator} + disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')} {isCollapsed} aiId="sidebar-menu-link-audit-logs" aiDescription="Button to navigate to audit logs" diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index b43bc2988e..d8f27ff0c6 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -12,31 +12,25 @@ } from '$lib/stores' import { isForkOwner } from '$lib/utils/workspaceHierarchy' import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts' + import { NAV_PAGES } from './navPages' import { BookOpen, ServerCog, - Boxes, Calendar, - DollarSign, Eye, Logs, FolderCog, - FolderOpen, Github, HelpCircle, - Home, LogOut, Newspaper, - Play, Route, Settings, UserCog, - Users, Plus, Unplug, AlertCircle, Database, - Pyramid, MailIcon, ChevronDown, ChevronRight @@ -72,6 +66,7 @@ import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte' import AzureIcon from '../icons/triggers/AzureIcon.svelte' import { leaveCurrentWorkspace } from './leaveWorkspace' + import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes' import { markChangelogsOpened, readRecentChangelogs } from './changelogs' const { recent: recentChangelogs, hasNew } = readRecentChangelogs() @@ -188,78 +183,63 @@ return count } + const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) + function allows(key: OperatorPageKey) { + return sidebarPageAllowed($userStore?.operator, currentWs, key) + } + const itemClass = twMerge( 'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' ) - let mainMenuLinks = $derived( - [ - { - label: 'Home', - href: `${base}/`, - icon: Home, - aiId: 'sidebar-menu-link-home', - aiDescription: - "Button to navigate to home which contains all the user's scripts, flows and apps" - }, - { - label: 'Runs', - href: `${base}/runs`, - icon: Play, - aiId: 'sidebar-menu-link-runs', - aiDescription: 'Button to navigate to runs', - onclick: () => { - setTimeout(() => { - window.dispatchEvent(new Event('popstate')) - }, 100) - } - }, - { - label: 'Variables', - href: `${base}/variables`, - icon: DollarSign, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-variables', - aiDescription: 'Button to navigate to variables' - }, - { - label: 'Resources', - href: `${base}/resources`, - icon: Boxes, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-resources', - aiDescription: 'Button to navigate to resources' - }, - { - label: 'Assets', - href: `${base}/assets`, - icon: Pyramid, - aiId: 'sidebar-menu-link-assets', - aiDescription: 'Button to navigate to assets' - }, - { - label: 'Folders', - href: `${base}/folders`, - icon: FolderOpen, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-folders', - aiDescription: 'Button to navigate to folders' - }, - { - label: 'Groups', - href: `${base}/groups`, - icon: Users, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-groups', - aiDescription: 'Button to navigate to groups' + // Labels, paths and icons come from the shared page table, so the nav and the page header's + // breadcrumb always call a page the same thing. Everything else here is nav-only. + const MAIN_MENU_EXTRAS: Record< + string, + { aiId: string; aiDescription: string; onclick?: () => void } + > = { + Home: { + aiId: 'sidebar-menu-link-home', + aiDescription: + "Button to navigate to home which contains all the user's scripts, flows and apps" + }, + Runs: { + aiId: 'sidebar-menu-link-runs', + aiDescription: 'Button to navigate to runs', + onclick: () => { + setTimeout(() => { + window.dispatchEvent(new Event('popstate')) + }, 100) } - ].filter((l) => !excludeMainLabels.includes(l.label)) + }, + Variables: { + aiId: 'sidebar-menu-link-variables', + aiDescription: 'Button to navigate to variables' + }, + Resources: { + aiId: 'sidebar-menu-link-resources', + aiDescription: 'Button to navigate to resources' + }, + Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' }, + Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' }, + Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' } + } + let mainMenuLinks = $derived( + NAV_PAGES.filter((p) => p.label in MAIN_MENU_EXTRAS) + .map((p) => ({ + label: p.label, + href: p.path === '/' ? `${base}/` : `${base}${p.path}`, + icon: p.icon, + disabled: p.operatorKey ? !allows(p.operatorKey) : false, + ...MAIN_MENU_EXTRAS[p.label] + })) + .filter((l) => !excludeMainLabels.includes(l.label)) ) let defaultExtraTriggerLinks = $derived([ { label: 'HTTP', href: '/routes', icon: Route, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'http', aiId: 'sidebar-menu-link-http', aiDescription: 'Button to navigate to HTTP routes' @@ -268,7 +248,7 @@ label: 'WebSockets', href: '/websocket_triggers', icon: Unplug, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'ws', aiId: 'sidebar-menu-link-ws', aiDescription: 'Button to navigate to websocket triggers' @@ -277,7 +257,7 @@ label: 'Postgres', href: '/postgres_triggers', icon: Database, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'postgres', aiId: 'sidebar-menu-link-postgres', aiDescription: 'Button to navigate to Postgres triggers' @@ -286,7 +266,7 @@ label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'), href: '/kafka_triggers', icon: KafkaIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'kafka', aiId: 'sidebar-menu-link-kafka', aiDescription: 'Button to navigate to Kafka triggers' @@ -295,7 +275,7 @@ label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/nats_triggers', icon: NatsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'nats', aiId: 'sidebar-menu-link-nats', aiDescription: 'Button to navigate to NATS triggers' @@ -304,7 +284,7 @@ label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/sqs_triggers', icon: AwsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'sqs', aiId: 'sidebar-menu-link-sqs', aiDescription: 'Button to navigate to SQS triggers' @@ -313,7 +293,7 @@ label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'), href: '/gcp_triggers', icon: GoogleCloudIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'gcp', aiId: 'sidebar-menu-link-gcp', aiDescription: 'Button to navigate to GCP Pub/Sub triggers' @@ -322,7 +302,7 @@ label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'), href: '/azure_triggers', icon: AzureIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'azure', aiId: 'sidebar-menu-link-azure', aiDescription: 'Button to navigate to Azure Event Grid triggers' @@ -331,7 +311,7 @@ label: 'MQTT', href: '/mqtt_triggers', icon: MqttIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'mqtt', aiId: 'sidebar-menu-link-mqtt', aiDescription: 'Button to navigate to MQTT triggers' @@ -340,7 +320,7 @@ label: 'AMQP', href: '/amqp_triggers', icon: AmqpIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'amqp', aiId: 'sidebar-menu-link-amqp', aiDescription: 'Button to navigate to AMQP triggers' @@ -349,7 +329,7 @@ label: 'Email', href: '/email_triggers', icon: MailIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'email', aiId: 'sidebar-menu-link-email', aiDescription: 'Button to navigate to Email triggers' @@ -375,7 +355,7 @@ label: 'Schedules', href: `${base}/schedules`, icon: Calendar, - disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator, + disabled: !SIDEBAR_SHOW_SCHEDULES || !allows('schedules'), aiId: 'sidebar-menu-link-schedules', aiDescription: 'Button to navigate to schedules' }, @@ -390,7 +370,6 @@ ) // Admins, superadmins, and fork creators reach workspace settings (the latter // for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user). - const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) const canManageWorkspace = $derived( $userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email) ) @@ -460,7 +439,7 @@ label: 'Workers', href: `${base}/workers`, icon: ServerCog, - disabled: $userStore?.operator, + disabled: !allows('workers'), aiId: 'sidebar-menu-link-workers', aiDescription: 'Button to navigate to workers' }, @@ -509,7 +488,7 @@ label: 'Audit logs', href: `${base}/audit_logs`, icon: Eye, - disabled: $userStore?.operator, + disabled: !allows('audit_logs'), aiId: 'sidebar-menu-link-audit-logs', aiDescription: 'Button to navigate to audit logs' } @@ -551,7 +530,7 @@ {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} {/each} - {#if extraTriggerLinks.length > 0 && !$userStore?.operator} + {#if extraTriggerLinks.length > 0 && allows('triggers')} {#snippet triggr({ trigger })} - import { untrack } from 'svelte' - import { melt } from '@melt-ui/svelte' - import type { MenubarMenuBuilders, MenubarMenuElements } from '@melt-ui/svelte' - import { Building, ChevronRight } from 'lucide-svelte' - import { twMerge } from 'tailwind-merge' - import { sidebarClasses } from './MenuButton.svelte' - import WorkspacePickerBody from './WorkspacePickerBody.svelte' - import { MenuItem } from '$lib/components/meltComponents' - import { clearWorkspaceFromStorage } from '$lib/stores' - import { base } from '$lib/base' - - interface Props { - // Submenu builders from the enclosing melt Menu — createSubmenu must be - // called against this specific menu instance (same pattern as DropdownSubmenuItem). - builders: MenubarMenuBuilders - // Melt uses the parent menu's item builder for submenu rows too, so the - // picker body's MenuItems work here unchanged. - item: MenubarMenuElements['item'] - } - - let { builders, item }: Props = $props() - - const { - elements: { subTrigger, subMenu }, - states: { subOpen } - } = untrack(() => builders).createSubmenu() - - - - -{#if $subOpen} -
- - -
- clearWorkspaceFromStorage()} - class={twMerge( - 'w-full flex flex-row gap-2 px-4 py-2 font-normal', - sidebarClasses.text, - 'hover:bg-surface-hover hover:text-primary', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - All workspaces - -
-
-{/if} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index ddcbd9ca95..3902715d5a 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -14,9 +14,19 @@ createMenu: MenubarBuilders['createMenu'] // When used outside of the side bar, where links to workspace settings and such don't make as much sense. strictWorkspaceSelect?: boolean + // Denser trigger for the page header bar: a smaller disc and a lighter name, so the + // workspace reads as the scope of the breadcrumb beside it rather than a heading. + compact?: boolean } - let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props() + let { + isCollapsed = false, + createMenu, + strictWorkspaceSelect = false, + compact = false + }: Props = $props() + + let menu: Menu | undefined = $state(undefined) // The active workspace's family root — shown in the trigger so a forked active workspace still // surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly @@ -29,7 +39,7 @@ const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces)) - + {#snippet triggr({ trigger })} @@ -41,7 +51,7 @@ name={currentFamily?.name ?? $workspaceStore ?? ''} id={currentFamily?.id ?? $workspaceStore ?? ''} disablePopup={isCollapsed} - class="block w-full" + class={compact ? 'block' : 'block w-full'} > @@ -63,6 +75,11 @@ {#snippet children({ item })} - + menu?.close()} + /> {/snippet} diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index fa4468fa6a..e25dc83286 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -1,5 +1,5 @@ -
+
{#snippet trigger()} {/snippet} diff --git a/frontend/src/lib/components/sidebar/navDetached.svelte.ts b/frontend/src/lib/components/sidebar/navDetached.svelte.ts new file mode 100644 index 0000000000..19568eaddc --- /dev/null +++ b/frontend/src/lib/components/sidebar/navDetached.svelte.ts @@ -0,0 +1,28 @@ +import { fromStore } from 'svelte/store' +import { userStore } from '$lib/stores' +import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' + +// One preference per role rather than one per account. The same person is both — an operator in +// one workspace, a developer in the next — and the two want different things from the sidebar, so +// a single key would carry whichever was pressed last into the other role and never give it back. +// Empty until that role presses detach or attach once. +const operatorPref = useLocalStorageValue('nav_detached_operator', '', 'string') +const devPref = useLocalStorageValue('nav_detached_dev', '', 'string') +const user = fromStore(userStore) + +/** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */ +export const navDetached = { + get val(): boolean { + // Unasked, operators start detached: many of them navigate through a workspace's own app, + // and a docked sidebar would take its width. + const operator = !!user.current?.operator + const pref = operator ? operatorPref : devPref + if (pref.val === 'true') return true + if (pref.val === 'false') return false + return operator + }, + set val(detached: boolean) { + const pref = user.current?.operator ? operatorPref : devPref + pref.val = String(detached) + } +} diff --git a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts new file mode 100644 index 0000000000..5fb7b43860 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts @@ -0,0 +1,50 @@ +import { BAR_HEIGHT_PX } from '../pageHeaderRegistry.svelte' + +// Opening the detached sidebar's card, from the band's own handle or from the corner control that +// stands in for it on a page which hides the band. +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 + +// 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 +// time rather than fixed, so the card follows the header's own height. On a page that hides the +// header, the header is still what the card will sit under — it comes down with it — so its height +// is read even while it is off-screen. The fallback matches the header's resting height. +const FALLBACK_CARD_TOP = BAR_HEIGHT_PX +const GAP_UNDER_HANDLE = 6 +let cardTop = $state(FALLBACK_CARD_TOP) + +export const navHandleSlot = { + /** Distance from the top of the viewport to the card's top edge, in px. */ + 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 }) { + opener = open + hoverClose = close + }, + /** Pointer left the handle: close, unless it lands on the card before the delay is up. */ + scheduleClose() { + hoverClose?.schedule() + }, + /** + * Opens the card under the page header, or under `anchor` when there is no header to sit + * beneath — an embed, where whatever reached for the nav is all the card has to hang from. + */ + open(anchor?: Element | null) { + hoverClose?.cancel() + const band = document.querySelector('[data-page-header]') + const bandHeight = band?.getBoundingClientRect().height + if (bandHeight) { + cardTop = Math.round(bandHeight) + } else { + const bottom = anchor?.getBoundingClientRect().bottom + cardTop = + bottom != null && bottom > 0 ? Math.round(bottom) + GAP_UNDER_HANDLE : FALLBACK_CARD_TOP + } + opener?.() + } +} diff --git a/frontend/src/lib/components/sidebar/navPages.test.ts b/frontend/src/lib/components/sidebar/navPages.test.ts new file mode 100644 index 0000000000..6e68912a15 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { navPageFor } from './navPages' + +describe('navPageFor', () => { + it('names the page a route belongs to, under the app base', () => { + expect(navPageFor('/base/runs', '/base')?.label).toBe('Runs') + }) + + it('keeps an item route on its page', () => { + expect(navPageFor('/runs/f/demo/x', '')?.label).toBe('Runs') + }) + + it('does not let Home swallow every route', () => { + expect(navPageFor('/audit_logs', '')?.label).toBe('Audit logs') + expect(navPageFor('/', '')?.label).toBe('Home') + }) + + it('falls back to the first segment for a route the table does not name', () => { + expect(navPageFor('/service_logs/x', '')).toMatchObject({ + label: 'Service logs', + path: '/service_logs' + }) + }) +}) diff --git a/frontend/src/lib/components/sidebar/navPages.ts b/frontend/src/lib/components/sidebar/navPages.ts new file mode 100644 index 0000000000..b9dc2eff63 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.ts @@ -0,0 +1,68 @@ +import { + Boxes, + Calendar, + DollarSign, + Eye, + FolderOpen, + Home, + HardHat, + Play, + Pyramid, + Unplug, + Users +} from 'lucide-svelte' +import type { OperatorPageKey } from './operatorRoutes' + +/** + * The names the page header's breadcrumb gives the workspace's own pages, and the rows the + * sidebar's main group is built from. + * + * Every entry names a page for the breadcrumb, and its `icon` goes there too. Only the entries + * `SidebarContent`'s `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, + * HTTP routes, Workers and Audit logs are grouped differently there and carry their own row, so + * for those four the `operatorKey` below goes unread. Keep the labels in step with those rows by + * hand. + */ +export type NavPage = { + label: string + /** Path under `base`, matched against the current route by longest prefix. */ + path: string + icon: any + /** The `operator_settings` key that admits an operator, when the page has one. */ + operatorKey?: OperatorPageKey +} + +export const NAV_PAGES: NavPage[] = [ + { label: 'Home', path: '/', icon: Home }, + { label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' }, + { label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' }, + { label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' }, + { label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' }, + { label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' }, + { label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' }, + { label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' }, + // "Triggers" is the group the sidebar files this under, not the page: the row there reads HTTP. + { label: 'HTTP routes', path: '/routes', icon: Unplug, operatorKey: 'triggers' }, + { label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' }, + { label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' } +] + +/** + * The page a route belongs to. Longest match wins, so `/runs/f/demo/x` is still Runs, and a route + * the table does not name falls back to its first segment (`/service_logs` → "Service logs"). + */ +export function navPageFor(pathname: string, base: string): NavPage | undefined { + const path = pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname + const match = NAV_PAGES.filter( + (p) => p.path === path || (p.path !== '/' && path.startsWith(p.path + '/')) + ).sort((a, b) => b.path.length - a.path.length)[0] + if (match) return match + const first = path.split('/').filter(Boolean)[0] + if (!first) return undefined + const label = first.replace(/_/g, ' ') + return { + label: label.charAt(0).toUpperCase() + label.slice(1), + path: `/${first}`, + icon: undefined + } +} diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.test.ts b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts new file mode 100644 index 0000000000..bf14926d00 --- /dev/null +++ b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { sidebarPageAllowed } from './operatorRoutes' +import type { UserWorkspace } from '$lib/stores' + +const ws = (operator_settings: any): UserWorkspace => + ({ id: 'w', name: 'w', username: 'u', operator_settings }) as UserWorkspace + +describe('sidebarPageAllowed', () => { + it('shows every page to anyone who is not an operator', () => { + expect(sidebarPageAllowed(false, ws(null), 'runs')).toBe(true) + expect(sidebarPageAllowed(undefined, undefined, 'runs')).toBe(true) + }) + + it('admits an operator only where the workspace turned the page on', () => { + expect(sidebarPageAllowed(true, ws({ runs: true }), 'runs')).toBe(true) + expect(sidebarPageAllowed(true, ws({ runs: false }), 'runs')).toBe(false) + }) + + it('withholds a page the settings do not mention', () => { + // `=== true`, not truthiness: a right absent from the jsonb, or settings that were never + // written, must not read as granted. + expect(sidebarPageAllowed(true, ws({ variables: true }), 'runs')).toBe(false) + expect(sidebarPageAllowed(true, ws(null), 'runs')).toBe(false) + expect(sidebarPageAllowed(true, undefined, 'runs')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts index 1abd7aeb93..6824c36c62 100644 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ b/frontend/src/lib/components/sidebar/operatorRoutes.ts @@ -1,61 +1,41 @@ -import { base } from '$lib/base' +import type { OperatorSettings } from '$lib/gen' import type { UserWorkspace } from '$lib/stores' /** - * The pages an operator can reach, each paired with the `operator_settings` key - * that admits them — the operator menu lists only the entries the active - * workspace permits. + * An `operator_settings` key that admits an operator to one page (`triggers` to all of them). + * + * Only the visibility flags: the same object also carries the write rights `builder_flows`, + * `manage_schedules` and `manage_triggers`, which the `=== true` rule below answers wrongly. A + * write right is granted unless withdrawn, so its absence means "never configured" rather than + * "no" — see `docs/operator-write-rights.md`. */ -export type OperatorMenuLink = { - label: string - /** `operator_settings` key gating the page. */ - id: string - href: string +export type OperatorPageKey = Extract< + keyof NonNullable, + | 'runs' + | 'schedules' + | 'resources' + | 'variables' + | 'assets' + | 'audit_logs' + | 'triggers' + | 'groups' + | 'folders' + | 'workers' +> + +/** + * Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page + * (other gates, such as admin-only entries, still apply); an operator sees only the pages the + * workspace's `operator_settings` turn on. + */ +export function sidebarPageAllowed( + isOperator: boolean | undefined, + workspace: UserWorkspace | undefined, + key: OperatorPageKey +): boolean { + return !isOperator || workspace?.operator_settings?.[key] === true } -export type OperatorTriggerLink = OperatorMenuLink & { kind: string } - -/** Home needs no setting — it is the one page every operator always has. */ -export const OPERATOR_MAIN_LINKS: OperatorMenuLink[] = [ - { label: 'Home', id: 'home', href: `${base}/` }, - { label: 'Runs', id: 'runs', href: `${base}/runs` }, - { label: 'Schedules', id: 'schedules', href: `${base}/schedules` } -] - -export const OPERATOR_SECONDARY_LINKS: OperatorMenuLink[] = [ - { label: 'Resources', id: 'resources', href: `${base}/resources` }, - { label: 'Variables', id: 'variables', href: `${base}/variables` }, - { label: 'Assets', id: 'assets', href: `${base}/assets` }, - { label: 'Groups', id: 'groups', href: `${base}/groups` }, - { label: 'Folders', id: 'folders', href: `${base}/folders` }, - { label: 'Workers', id: 'workers', href: `${base}/workers` }, - { label: 'Audit logs', id: 'audit_logs', href: `${base}/audit_logs` } -] - -export const OPERATOR_TRIGGER_LINKS: OperatorTriggerLink[] = [ - { label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' }, - { label: 'Websocket triggers', id: 'triggers', href: `${base}/websocket_triggers`, kind: 'ws' }, - { - label: 'Postgres triggers', - id: 'triggers', - href: `${base}/postgres_triggers`, - kind: 'postgres' - }, - { label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' }, - { label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' }, - { label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' }, - { label: 'GCP Pub/Sub triggers', id: 'triggers', href: `${base}/gcp_triggers`, kind: 'gcp' }, - { - label: 'Azure Event Grid triggers', - id: 'triggers', - href: `${base}/azure_triggers`, - kind: 'azure' - }, - { label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' }, - { label: 'AMQP triggers', id: 'triggers', href: `${base}/amqp_triggers`, kind: 'amqp' }, - { label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' } -] - /** * Whether the user is an operator in `workspace`. The server nulls * `operator_settings` for a non-operator, so a non-null value narrows an operator diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts index df8e668fba..24925f2198 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts @@ -46,6 +46,43 @@ describe('workspaceMenuHref', () => { ).toBe('/runs?workspace=w') }) + // The compare page reads the workspace it compares from `workspace_id`, so a switch that + // left it behind sent the page back to the workspace just left on the next reload. + it('swaps workspace_id alongside workspace', () => { + expect( + workspaceMenuHref({ + pathname: '/forks/compare', + searchParams: new URLSearchParams('workspace_id=wm-fork-a&mode=fork'), + id: 'wm-fork-b', + sameFamily: true + }) + ).toBe('/forks/compare?workspace_id=wm-fork-b&mode=fork&workspace=wm-fork-b') + }) + + it('does not add workspace_id to a page that had none', () => { + expect( + workspaceMenuHref({ + pathname: '/runs', + searchParams: new URLSearchParams('job=123'), + id: 'w', + sameFamily: true + }) + ).toBe('/runs?job=123&workspace=w') + }) + + // `target` is the compare page's one-off migration destination, picked for the workspace + // being left. Carried over it can point the new workspace at itself. + it('drops an arbitrary compare target', () => { + expect( + workspaceMenuHref({ + pathname: '/forks/compare', + searchParams: new URLSearchParams('workspace_id=wm-fork-a&target=wm-fork-b&mode=fork'), + id: 'wm-fork-b', + sameFamily: true + }) + ).toBe('/forks/compare?workspace_id=wm-fork-b&mode=fork&workspace=wm-fork-b') + }) + // An operator lands on home in the workspace they switch into. The href has to carry // that too, not just the click handler: this is what a middle-click opens. it('points at home, dropping the current page and its params, when landOnHome is set', () => { diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts index 57342f2b08..589326c0f6 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts @@ -24,6 +24,17 @@ export function workspaceMenuHref(args: { } const params = new URLSearchParams(args.searchParams) params.set('workspace', args.id) + // `workspace_id` names the workspace a page acts on rather than the one the app is pointed at + // — the compare page's source. A switch moves it too, or the link lands on a page still + // working on the workspace just left. Only rewritten, never added: a page that does not use + // it has no business gaining it. + if (params.has('workspace_id')) { + params.set('workspace_id', args.id) + } + // The compare page's one-off migration destination, chosen for the workspace being left. Kept, + // it would survive into a workspace it was never picked for — including the target itself, + // leaving that page comparing a workspace with itself. + params.delete('target') if (!args.sameFamily) { params.delete('session') params.delete('session_name') diff --git a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts index 82130e3f95..c8bc6d070c 100644 --- a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts +++ b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts @@ -1,13 +1,20 @@ import { page } from '$app/state' -import { replaceState } from '$app/navigation' import { goto } from '$lib/navigation' // Post-workspace-switch URL fixup shared by the sidebar workspace pickers. // Item-scoped pages would show a wrong-workspace (or missing) item after a -// switch — go home instead. Otherwise, really rewrite a ?workspace= param in -// the address bar (mutating page.url is a no-op there): left stale, it gets -// re-applied on reload or when exiting session mode restores the route, +// switch — go home instead. Otherwise, really rewrite the params in the address +// bar that name a workspace (mutating page.url is a no-op there): left stale, +// they get re-applied on reload or when exiting session mode restores the route, // silently switching the workspace back. +// +// `workspace_id` names the workspace a page acts on (the compare page's pair) and +// `workspace` the one the app is pointed at; a switch moves both. `target`, the +// compare page's one-off migration destination, is dropped instead of rewritten: +// it was chosen for the workspace being left, and keeping it could point the new +// one at itself. +const WORKSPACE_PARAMS = ['workspace', 'workspace_id'] +const STALE_AFTER_SWITCH = ['target'] const EDIT_PAGES = [ '/scripts/edit/', '/flows/edit/', @@ -28,9 +35,32 @@ export async function fixupUrlAfterWorkspaceSwitch( ): Promise { if (EDIT_PAGES.some((p) => page.route.id?.includes(p) ?? false) || opts?.landOnHome) { await goto('/') - } else if (page.url.searchParams.get('workspace')) { - const url = new URL(window.location.href) - url.searchParams.set('workspace', id) - replaceState(url, page.state) + return } + // `window.location`, never `page.url`: a param written by a previous shallow update + // is in the address bar but not in `page.url`, so rebuilding from it would undo that + // write (see the note on `setQuery` in $lib/navigation). + const url = new URL(window.location.href) + let changed = false + for (const key of WORKSPACE_PARAMS) { + if (url.searchParams.get(key) === id) continue + if (!url.searchParams.has(key)) continue + url.searchParams.set(key, id) + changed = true + } + for (const key of STALE_AFTER_SWITCH) { + if (!url.searchParams.has(key)) continue + url.searchParams.delete(key) + changed = true + } + if (!changed) return + // `goto` rather than `replaceState`: a page reading these params through `page.url` + // (the compare page derives the workspace it compares from them) sees nothing when + // they are written shallowly, since SvelteKit refreshes `page.url` only on a + // router-driven navigation. + await goto(`${url.pathname}${url.search}${url.hash}`, { + replaceState: true, + noScroll: true, + keepFocus: true + }) } diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte index cc7f4ab4a5..8c414cac7a 100644 --- a/frontend/src/lib/components/tutorials/OperatorTour.svelte +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -1,8 +1,7 @@ -
+
| undefined>(undefined) let agent = $derived(host?.draftHandle()) let testPane = $derived(host?.testPaneHandle()) @@ -86,11 +88,25 @@ Every flow that links {path} will fail at its agent step once it is deleted. -
+
+ { + // A rename moves the page; a summary saved in place has to be read again, since + // what is shown here came from the load this page keys on. + if (newPath !== path) await goto(`${base}/agents/get/${newPath}`) + else reloaded++ + } + : undefined} mainButtons={[ // Evaluating an agent builds datasets and runs against it: authoring, as editing is. ...(canEdit @@ -154,7 +170,7 @@ {/snippet}
- {#key `${ws}:${path}`} + {#key `${ws}:${path}:${reloaded}`} +
{ goto(`/apps/edit/${url}`) diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index 4ff8670cda..c911a8a64c 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -24,7 +24,7 @@ must fully remount — otherwise the previous app (and in sandbox mode its path-scoped token) sticks around. --> {#key `${workspace}/${path}`} - + {/key} {:else} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index fec356d89a..9ed2ca296a 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -624,9 +624,11 @@ {#if files} {#key redraw} -
+
goto(`/apps_raw/get/${e.path}?workspace=${$workspaceStore}`)} onSavedNewAppPath={(savedPath) => { draftSync.remove() goto(`/apps_raw/edit/${savedPath}`) diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte index 79c8145731..672b0f9feb 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte @@ -18,7 +18,7 @@ {#key `${workspace}/${path}`} - + {/key} {:else} diff --git a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte index 59c961febb..42d8ff996e 100644 --- a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte @@ -7,6 +7,8 @@ import AuditLogsTable from '$lib/components/auditLogs/AuditLogsTable.svelte' import AuditLogMobileFilters from '$lib/components/auditLogs/AuditLogMobileFilters.svelte' import { Alert, DrawerContent, Skeleton } from '$lib/components/common' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte' @@ -64,11 +66,26 @@ ) let auditLogDrawer: Drawer | undefined = $state() - // The inline filters show only when their one-line width fits in the space the title leaves. - // Both are measured, so the sidebar, side panels, zoom and filter values are all accounted for. - let filtersSlotWidth = $state(0) + // The inline filters show only when their one-line width fits the bar. The row is measured, so + // the sidebar, side panels, zoom and the filters' own values are all accounted for; what it is + // measured against is the bar, not the box it sits in. That box is the band's action area, + // whose width is its own content's — measuring the row against it would ask whether the row + // fits inside itself, which it always does, and the filters would never fold. let filtersRowWidth = $state(0) - let inlineFilters = $derived(filtersRowWidth > 0 && filtersRowWidth <= filtersSlotWidth) + /** What the breadcrumb takes before the filters get any: the workspace disc and name, the + * page's name and the hint beside it. Measured at 361-398px across bar widths. */ + const BREADCRUMB_WIDTH = 400 + /** Clear space held between the breadcrumb and the first filter. The breadcrumb yields width + * grudgingly and the filter row not at all, so when the two are sized to just meet they + * overlap instead of compressing — and mid-resize, before the breadcrumb has settled, the + * tooltip at the end of it lands on the timeframe button. Measured without this: the gap + * reached 4px across a 1600->1200 sweep. */ + const BREADCRUMB_GAP = 48 + let inlineFilters = $derived( + filtersRowWidth > 0 && + pageHeader.barWidth > 0 && + filtersRowWidth <= pageHeader.barWidth - BREADCRUMB_WIDTH - BREADCRUMB_GAP + ) // Function to fetch missing job execution audit logs async function fetchMissingJobSpan(jobId: string, jobLogs: AuditLog[]): Promise { @@ -117,19 +134,21 @@

Page not available for operators

{:else} -
-
-
-

Audit logs

- - You can only see your own audit logs unless you are an admin. - -
- -
+ +
+ + + + {#snippet auditHint()} + + You can only see your own audit logs unless you are an admin. + + {/snippet} + + {#snippet auditActions()} +
-
+ {/snippet} {#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 835735c0c8..d724c4873d 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -557,6 +557,7 @@
{:else if renderEditor} { // The version the drawer showed as head; see /scripts/edit. diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index a21fa36835..aa158d2f11 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -1,4 +1,5 @@ + + +
@@ -440,10 +505,12 @@ onModeSelected={selectMode} /> {:else if compareTargetId} - - {#key compareTargetId} + + {#key pairKey} Pipelines — Windmill -
-
-
- -

Pipelines

- - Alpha - -
-
+ + +{#snippet alphaBadge()} + + Alpha + +{/snippet} + +
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index b4ddb8350c..a16b1a1e49 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -2,6 +2,7 @@ import { workspaceStore, userStore } from '$lib/stores' import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths' import { base } from '$lib/base' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { page } from '$app/state' import Button from '$lib/components/common/button/Button.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' @@ -98,7 +99,6 @@ FolderSearch, History, Loader2, - NetworkIcon, Play, RefreshCw, Save, @@ -2386,60 +2386,71 @@ Pipeline · {folder} — Windmill -
-
-
- + {:else} + + {#snippet buttonReplacement()} + f/{folder} - - {:else} - - {#snippet buttonReplacement()} - - - f/{folder} - - - {/snippet} - - {/if} - {#if summary.length > 0} - · {summary.join(' · ')} - {/if} -
+ + {/snippet} + + {/if} + {#if summary.length > 0} + · {summary.join(' · ')} + {/if} +{/snippet} + +{#snippet pipelineActions()} + +
{#if !isOperator} - -
+ +
setMode(m)} /> {#if mode === 'view' && pe.drafts.size > 0} + graph — "what View will show once they're deployed". -->
{/if} -
- {#if !isOperator && allPipelineScripts.length > 0} - - {#if recordingMode} + {#if !isOperator && allPipelineScripts.length > 0} + + {#if recordingMode} + + {/if} + + + {/if} + {#if mode === 'edit' && saveErrors.size > 0} + + + {#snippet trigger()} - {/if} - - - {/if} - {#if mode === 'edit' && saveErrors.size > 0} - - - {#snippet trigger()} - - {/snippet} - {#snippet content()} -
- Save errors -
- {#each [...saveErrors.entries()] as [path, message]} -
- {path} - - {message} - -
- {/each} -
+ {/snippet} + {#snippet content()} +
+ Save errors +
+ {#each [...saveErrors.entries()] as [path, message]} +
+ {path} + + {message} + +
+ {/each}
- {/snippet} - - {/if} - {#if mode === 'edit' && pe.drafts.size > 0} - - {#if $workspaceStore} - - {/if} - - {/if} - {#if mode === 'view'} - +
+ {/snippet} + + {/if} + {#if mode === 'edit' && pe.drafts.size > 0} + + {#if $workspaceStore} + {/if}
+ startIcon={{ icon: savingAll ? Loader2 : Save }} + onclick={saveAllDrafts} + disabled={savingAll} + title={savingAll ? 'Saving drafts…' : `Deploy all ${pe.drafts.size} drafts`} + > + {savingAll ? 'Saving…' : `Save all (${pe.drafts.size})`} + + {/if} + {#if mode === 'view'} + + {/if} +
+{/snippet} +
{#if graphRes.loading && !graphRes.current}
diff --git a/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte index c702f66471..58739ee36a 100644 --- a/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte @@ -58,7 +58,7 @@ }) - + {#if status === 'connecting'}
{:else if status === 'connected'} diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 183d10a003..48d667cc35 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -70,7 +70,9 @@ import HighlightCode from '$lib/components/HighlightCode.svelte' import JobLoader from '$lib/components/JobLoader.svelte' import LogViewer from '$lib/components/LogViewer.svelte' - import { ActionRow, Button, Skeleton, Tab, Alert, DrawerContent } from '$lib/components/common' + import { Button, Skeleton, Tab, Alert, DrawerContent } from '$lib/components/common' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import JobDetailHeader from '$lib/components/runs/JobDetailHeader.svelte' import ScriptRetryChain from '$lib/components/runs/ScriptRetryChain.svelte' import FlowExecutionStatus from '$lib/components/runs/FlowExecutionStatus.svelte' @@ -648,6 +650,10 @@ } }) onDestroy(resetFavicon) + + // The bar shares its row with the breadcrumb, and this page's name is a uuid that gives way + // grudgingly, so the buttons drop their labels rather than push each other off the end. + const compactBar = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < 1150) @@ -805,289 +811,305 @@ class="max-w-7xl p-4 mx-auto w-full" loading={!job} layout={[ - // 1. Top Action Bar (buttons on right side) - [ - { h: 2.5, w: 60 }, - { h: 2.5, w: 40 } - ], - 1, - // 2. Job Header + // 1. Job Header [{ h: 12, w: 100 }], 1, - // 3. Progress Bar + // 2. Progress Bar [{ h: 2, w: 100 }], 1.5 ]} /> - - {#snippet left()} -

run/{page.params.run}

- {/snippet} - {#snippet right()} - {@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) + + + + {#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} +
+ job && sharePublicLink(job.id) + displayName: 'Show Flow Debug Info', + action: () => { + debugInfo() + } } ]} + class="h-auto" > {#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 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} - {/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 canWrite(job?.script_path ?? '', {}, $userStore)} - - {#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'} - + {:else} + {/if} - {/snippet} -
+ {/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 canWrite(job?.script_path ?? '', {}, $userStore)} + + {#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} -{#snippet title()} -
-

Service logs

- - Logs written by the servers, workers and indexers of this instance, grouped by host. - -
-{/snippet} - -
+ +
{#if $devopsRole == false}
- {@render title()} Ask one of them to look at the logs, or to grant you the devops role.
{:else} - + {/if}
diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 1662c15d79..97ed4b95b6 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -1,21 +1,25 @@ - + +{#if $userStore?.operator} +
+ Page not available for operators +
+{:else} + +{/if} diff --git a/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte b/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte index 645a928473..408a2c1d55 100644 --- a/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte @@ -8,4 +8,4 @@ goto('/') - + diff --git a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte index 010abe2bd8..6e5f674b44 100644 --- a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte @@ -30,7 +30,7 @@ } - +

Token will be posted to {host == 'localhost' ? 'your local machine' : host} to port {port}

diff --git a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte index 2362b7f116..ae22b66147 100644 --- a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte @@ -73,7 +73,7 @@ } - + {#if framed} This page cannot be used inside another page. Open it in its own tab. diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index f055e92ca3..022048bbf7 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores' import { isCloudHosted } from '$lib/cloud' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Button, CopyButton, Section, Skeleton, Tab, Tabs } from '$lib/components/common' + import { Alert, Button, Section, Skeleton, Tab, Tabs } from '$lib/components/common' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -13,7 +13,7 @@ import ErrorOrRecoveryHandler, { handlerFullPath } from '$lib/components/ErrorOrRecoveryHandler.svelte' - import PageHeader from '$lib/components/PageHeader.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -1459,24 +1459,19 @@ ) - + {#if canAdmin || isForkOwner} - - {#snippet titleActions()} - {#if $workspaceStore} - - {/if} - {/snippet} - {#if $superadmin} - - {/if} + +
-
+ +
-
+
{#if !loadedSettings} {:else if tab == 'users'} @@ -2435,5 +2430,13 @@ export async function main( tabMode={true} /> +{#snippet settingsActions()} + {#if $superadmin} + + {/if} +{/snippet} + diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte index b25b8db5c8..62c25fc6b3 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte @@ -31,7 +31,7 @@ - + {#if !success}
diff --git a/frontend/src/routes/a/[...path]/+page.svelte b/frontend/src/routes/a/[...path]/+page.svelte index e9fd20877c..417216608e 100644 --- a/frontend/src/routes/a/[...path]/+page.svelte +++ b/frontend/src/routes/a/[...path]/+page.svelte @@ -187,25 +187,29 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte index ee83869ee8..85a72dd7e3 100644 --- a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte @@ -78,23 +78,28 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte index b2a29b6316..8229c5af6c 100644 --- a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte +++ b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte @@ -160,25 +160,29 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
From c860b025783d7a4fd5bf1ac4a0bbf6677bcf554c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 8 Oct 2026 08:50:32 +0200 Subject: [PATCH 098/174] describe team-plan seat billing accurately in workspace settings (#11603) Co-authored-by: Claude Opus 5.5 --- .../lib/components/settings/PremiumInfo.svelte | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index b96ab5e22b..766b2c6495 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -261,8 +261,7 @@
Developers

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

@@ -277,8 +276,7 @@
Operators

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

@@ -293,7 +291,7 @@
Service accounts

- Enabled service accounts, half a seat each like operators, whatever their role. + Half a seat each, whatever their role.

@@ -383,8 +381,9 @@ Used seats (billed)

- Highest between seats from 'Developers + Operators' and 'Seats from - executions'. This is the number of seats used for billing this month. + 'Seats from users' plus 'Extra seats from computations'. Billed on the + highest total reached during the month; your invoices in the Customer Portal + show what was billed.

u + c = {formatNumber(premiumInfo.usedSeats)} @@ -417,7 +416,7 @@

Estimate your monthly cost

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

From 8f7e0c72848b56fa97f6f0141d930c7d15044864 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 8 Oct 2026 08:52:59 +0200 Subject: [PATCH 099/174] feat: add gpt-6.1-sol to the chat, agent steps and weekly ai evals (#11584) Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- .github/workflows/ai-evals-scheduled.yml | 6 +++--- ai_evals/core/models.ts | 9 +++++++++ backend/windmill-ai/src/providers/mod.rs | 8 ++++++-- frontend/src/lib/components/copilot/lib.ts | 1 + frontend/src/lib/components/copilot/reasoningParity.json | 6 ++++++ frontend/src/lib/components/copilot/reasoningRegistry.ts | 5 +++-- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index 4780eb02eb..94ecc7c6c8 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -15,8 +15,8 @@ on: description: "Space-separated modes (global, cli, flow, app, script)" default: "global cli" models: - description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for" - default: "sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro" + description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"" + default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro" runs: description: "Runs per case" default: "3" @@ -28,7 +28,7 @@ concurrency: group: ai-evals-scheduled-${{ github.ref }} env: - MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro' }} + MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro' }} RUNS: ${{ inputs.runs || '3' }} REASONING: ${{ inputs.reasoning }} # Routes on the cloud instance are prefixed with their workspace. diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 22af20d5c3..cf131337fd 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -148,6 +148,15 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "gpt-6-astra", }, }, + { + id: "gpt-6.1-sol", + label: "GPT-6.1 Sol", + aliases: ["gpt-6.1-sol"], + frontend: { + provider: "openai", + model: "gpt-6.1-sol", + }, + }, { id: "gpt-6-sol", label: "GPT-6 Sol", diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index e08b038bb0..7dcddc93a3 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -62,9 +62,13 @@ const REASONING_RULES: &[ReasoningRule] = &[ can_disable: false, completions_tools_need_off: false, }, - // Live-verified: astra takes low..max only, where sol and luna also take `none`. + // Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and + // gpt-6-luna also take `none`. ReasoningRule { - matches: |m| base_id(m).starts_with("gpt-6-astra"), + matches: |m| { + let base = base_id(m); + base.starts_with("gpt-6-astra") || base.starts_with("gpt-6.1-sol") + }, can_disable: false, completions_tools_need_off: true, }, diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index c53028031d..1d111a2f20 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -71,6 +71,7 @@ interface AIProviderDetails { // the frontier model. The gpt-5 family is deprecated (retires 2026-12-11) but // still served, so it stays in the list below the 5.6 models. const OPENAI_MODELS = [ + 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-astra', 'gpt-6-luna', diff --git a/frontend/src/lib/components/copilot/reasoningParity.json b/frontend/src/lib/components/copilot/reasoningParity.json index 93b8ac7eb2..1f20def9be 100644 --- a/frontend/src/lib/components/copilot/reasoningParity.json +++ b/frontend/src/lib/components/copilot/reasoningParity.json @@ -47,6 +47,12 @@ "canDisable": false, "completionsToolsNeedOff": true }, + { + "provider": "openai", + "model": "gpt-6.1-sol", + "canDisable": false, + "completionsToolsNeedOff": true + }, { "provider": "openai", "model": "gpt-6-sol", diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 68934c22f3..0cbf935832 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -131,9 +131,10 @@ const BEDROCK_RULES: ReasoningRule[] = [ // Azure names gpt-3.5 `gpt-35-turbo`. `minimal` exists on gpt-5 only, `xhigh` from // gpt-5.5, `max` from gpt-5.6. const OPENAI_RULES: ReasoningRule[] = [ - // Live-verified: astra takes low..max only, where sol and luna also take `none`. + // Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and + // gpt-6-luna also take `none`. { - match: /(?:^|\/)gpt-6-astra/, + match: /(?:^|\/)gpt-6(?:-astra|\.1-sol)/, levels: LOW_TO_MAX, canDisable: false, completionsToolsNeedOff: true From d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:53:23 +0200 Subject: [PATCH 100/174] feat: improve data table roles and grants UX (#11562) * feat: improve data table roles and grants UX Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep selected bordered badges the same height as unselected ones Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse taking over roles with members and test grant statements Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep grant filter chips while a filter is set Co-Authored-By: Claude Opus 5.5 (1M context) * fix: check a taken-over role can log in with the new password Co-Authored-By: Claude Opus 5.5 (1M context) * feat: select several grant filter tags at once Co-Authored-By: Claude Opus 5.5 (1M context) * feat: explain where schema and table permissions are set Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the grant create row on one line Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show grants as a role and its privileges Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show grants in a table with role, privileges and scope Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide the grant scope when it can only be the object itself Co-Authored-By: Claude Opus 5.5 (1M context) * feat: grant schema usage along with grants inside the schema Co-Authored-By: Claude Opus 5.5 (1M context) * feat: confirm before closing the roles drawer with unsaved changes Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to c4002f777f56d90bb0471164291889850c85179d This commit updates the EE repository reference after PR #848 was merged in windmill-ee-private. Previous ee-repo-ref: 7937634a52d04caeb083da94e189705cd00796b3 New ee-repo-ref: c4002f777f56d90bb0471164291889850c85179d 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 +- .../tests/datatable_roles.rs | 83 +++++ .../src/datatable_acl.rs | 43 ++- backend/windmill-api/openapi.yaml | 13 + .../windmill-common/src/datatable_roles.rs | 12 +- .../src/datatable_roles_oss.rs | 3 +- frontend/src/lib/components/DBManager.svelte | 23 +- .../src/lib/components/DBManagerDrawer.svelte | 1 + .../src/lib/components/DropdownV2Inner.svelte | 16 +- .../lib/components/common/badge/Badge.svelte | 3 +- .../ConfirmationModal.svelte | 23 +- .../common/drawer/Disposable.svelte | 32 +- .../components/common/drawer/Drawer.svelte | 6 +- .../datatableAcl/PgAclEditor.svelte | 334 +++++++++++++----- .../datatableAcl/PgGrantBuilder.svelte | 96 +++-- .../components/datatableAcl/aclScopes.test.ts | 30 +- .../lib/components/datatableAcl/aclScopes.ts | 50 +-- .../DataTablePermissionsButton.svelte | 226 +++++++----- .../DataTableRolesSection.svelte | 31 +- .../workspaceSettings/datatableRoleModals.ts | 120 +++++++ 20 files changed, 864 insertions(+), 283 deletions(-) create mode 100644 frontend/src/lib/components/workspaceSettings/datatableRoleModals.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fffcc99883..560bcfb41b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -1600d49ad5513af8b54ad65688d3d1c7edc977d9 \ No newline at end of file +6f23819e947b18878000e580ddefccfd01a73166 diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index ddb94f8c2b..9800d97841 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -417,6 +417,89 @@ async fn concurrent_role_creations_both_survive(db: Pool) -> anyhow::R outcome } +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn an_existing_role_is_taken_over_only_when_asked_and_never_with_members( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); + let plain = format!("wmtest_plain_{suffix}"); + let group = format!("wmtest_group_{suffix}"); + let member = format!("wmtest_member_{suffix}"); + for sql in [ + // An expired password and no connection slot: kept as is, the login Windmill sets is dead. + format!( + "CREATE ROLE \"{plain}\" LOGIN PASSWORD 'theirs' VALID UNTIL '2000-01-01' \ + CONNECTION LIMIT 0" + ), + format!("CREATE ROLE \"{group}\" NOLOGIN"), + format!("CREATE ROLE \"{member}\" LOGIN"), + format!("GRANT \"{group}\" TO \"{member}\""), + ] { + sqlx::query(&sql).execute(&db).await?; + } + + let create = |name: String, take_over: bool| async move { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/settings/datatable_roles" + )), + "SECRET_TOKEN", + ) + .json(&json!({ "name": name, "take_over": take_over })) + .send() + .await?; + Ok::<_, anyhow::Error>((resp.status().as_u16(), resp.text().await?)) + }; + let outcome = async { + let (status, body) = create(plain.clone(), false).await?; + assert_eq!( + status, 409, + "an existing role is not taken without asking: {body}" + ); + + // The group's member would inherit every grant the data table role gets. + let (status, body) = create(group.clone(), true).await?; + assert_eq!(status, 400, "{body}"); + assert!( + body.contains(&member), + "the refusal names the member: {body}" + ); + + let (status, body) = create(plain.clone(), true).await?; + assert_eq!(status, 200, "{body}"); + let catalog = windmill_common::datatable_roles::read_role_catalog( + &db, + windmill_common::datatable_roles::DatatableRoleCluster::Instance, + ) + .await?; + let taken = catalog + .values() + .find(|r| r.name == plain) + .ok_or_else(|| anyhow::anyhow!("{plain} is not in the catalog: {catalog:?}"))?; + let options = (*db.connect_options()) + .clone() + .username(&plain) + .password(taken.pwd.as_deref().unwrap_or_default()); + let mut login = ::connect_with(&options).await?; + sqlx::query("SELECT 1").execute(&mut login).await?; + Ok::<_, anyhow::Error>(()) + } + .await; + + // Roles are cluster-wide and outlive this test's database, whatever happened above. + for name in [&member, &group, &plain] { + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\"")) + .execute(&db) + .await; + } + outcome +} + #[cfg(all(feature = "private", feature = "enterprise"))] #[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled( diff --git a/backend/windmill-api-workspaces/src/datatable_acl.rs b/backend/windmill-api-workspaces/src/datatable_acl.rs index ebab6214ce..efaa712481 100644 --- a/backend/windmill-api-workspaces/src/datatable_acl.rs +++ b/backend/windmill-api-workspaces/src/datatable_acl.rs @@ -683,6 +683,9 @@ pub(crate) struct CatalogFacts { pub(crate) former_owner: Option, /// For a revoke: each grant it takes back. pub(crate) revoked_grants: Vec, + /// For a grant on what a schema holds: the role cannot reach the schema yet, so the grant + /// would give it nothing it can use. + pub(crate) lacks_schema_usage: bool, } /// A grant a revoke takes back, as the catalog records it: what `source` gave on `object` (the @@ -1526,7 +1529,24 @@ async fn build_plan( ))); } } - let facts = CatalogFacts { other_pg_roles, existing_objects, former_owner, revoked_grants }; + let lacks_schema_usage = match (&change, target) { + (AclChange::Grant { scope, .. }, AclTarget::Schema { schema }) + if !matches!(scope, GrantScope::Target) => + { + !has_schema_usage(client, schema, &pg_role).await? + } + (AclChange::Grant { .. }, AclTarget::Table { schema, .. }) => { + !has_schema_usage(client, schema, &pg_role).await? + } + _ => false, + }; + let facts = CatalogFacts { + other_pg_roles, + existing_objects, + former_owner, + revoked_grants, + lacks_schema_usage, + }; let mut plan = crate::datatable_acl_oss::plan_statements(target, &change, dbname, &pg_role, &facts)?; if matches!(change, AclChange::SetOwner { .. }) { @@ -1540,6 +1560,27 @@ async fn build_plan( Ok(plan) } +/// Whether `pg_role` may look into `schema`, directly or through a role it inherits from. +async fn has_schema_usage( + client: &tokio_postgres::Client, + schema: &str, + pg_role: &str, +) -> Result { + Ok(client + .query_one( + "SELECT has_schema_privilege($1::name, $2::text, 'USAGE')", + &[&pg_role, &schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the role's privileges on schema {schema}: {}", + pg_error_message(&e) + )) + })? + .get(0)) +} + /// Postgres only hands an object to a role that could have created it: a table to one with /// `CREATE` on its schema, a schema to one with `CREATE` on the database. async fn missing_owner_privilege( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1b2aea604e..873fcb7405 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1765,6 +1765,12 @@ paths: type: string cluster: $ref: "#/components/schemas/DatatableRoleCluster" + take_over: + type: boolean + description: > + manage a Postgres role of that name that already exists on the cluster, + replacing its password, instead of refusing it. A role with cluster-wide + attributes or memberships is refused either way responses: "200": description: the created role @@ -1772,6 +1778,8 @@ paths: application/json: schema: $ref: "#/components/schemas/InstanceDatatableRole" + "409": + description: a Postgres role of that name already exists on the cluster and take_over was not set /settings/datatable_roles/{id}: post: @@ -34525,6 +34533,11 @@ components: type: boolean cluster: $ref: "#/components/schemas/DatatableRoleCluster" + in_use: + type: boolean + description: >- + Whether any data table, in any workspace, lets someone connect as this role. Only set + in a data table's available_roles. DatatableRoleTenants: type: object diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index f355e733cb..e7e490a6a4 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -331,6 +331,12 @@ pub async fn converge_connect_grants_with( /// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user` on `cluster`. No /// privileges beyond that — an admin grants them through SQL or the ACL editor. /// +/// A role of that name that already exists is refused with a 409, unless `take_over`: then its +/// password is replaced and it is managed from here on like one created here. A role holding +/// cluster-wide privileges, or inheriting another role's, is refused either way, since every tenant +/// would get them. Returns whether an existing role was taken over: undoing a failed take-over +/// must not drop a role that was there before. +/// /// On Windmill's own cluster the DDL runs on `tx`, so it commits with the catalog row. The external /// cluster is another server: the role is created there before `tx` commits, and callers MUST drop /// it again ([`drop_datatable_role`]) if `tx` then fails to commit. @@ -343,8 +349,10 @@ pub async fn create_datatable_role( cluster: DatatableRoleCluster, name: &str, password: &str, -) -> Result<()> { - crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password).await + take_over: bool, +) -> Result { + crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password, take_over) + .await } /// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin diff --git a/backend/windmill-common/src/datatable_roles_oss.rs b/backend/windmill-common/src/datatable_roles_oss.rs index a97a936827..33d711e7f9 100644 --- a/backend/windmill-common/src/datatable_roles_oss.rs +++ b/backend/windmill-common/src/datatable_roles_oss.rs @@ -125,7 +125,8 @@ mod ce { _cluster: DatatableRoleCluster, _name: &str, _password: &str, - ) -> Result<()> { + _take_over: bool, + ) -> Result { Err(unavailable()) } diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 5204a876b9..837569c575 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -375,7 +375,8 @@ } if (!isInstanceDatatable(datatable)) return { - reason: 'Only data tables stored in the instance database have roles and grants.', + reason: + "Roles and grants are not supported on data tables backed by a Postgres resource. Store the data table on Windmill's database or on an external Postgres instance (set up by a superadmin in instance settings) to use them.", ee: false } if (needsAdmin && !canManageDatatable) @@ -458,6 +459,11 @@ // default to open — actually be folded; a plain "expanded" set could never // close them, since the default would keep winning. // What the permissions drawer is open on, if anything: a schema, or a table. + let aclEditor = $state(undefined) + /** The open access editor lists the data table's roles, which the roles drawer can change. */ + export function refreshAcl() { + aclEditor?.refresh() + } let aclDrawer = $state<{ datatable: string | undefined; target: AclTarget } | undefined>( undefined ) @@ -1466,7 +1472,7 @@ (aclDrawer = undefined)}> (aclDrawer = undefined)} tooltip="Who owns this, and what each role may do with it." > @@ -1474,7 +1480,18 @@ {@const dt = aclDrawer.datatable ?? currentDatatable} {#if dt} {#key `${dt}~${JSON.stringify(aclDrawer.target)}`} - + onDatatableAction?.(dt, 'roles'), + blocker: accessBlocker(dt, true)?.reason + } + : undefined} + /> {/key} {/if} {/if} diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index bd016544b0..30fc93377d 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -311,6 +311,7 @@ function refreshRoles() { datatables.refetch() usableRoles.refetch() + dbManagerContent?.dbManager()?.refreshAcl() } async function handleExportSchema(explicitSource?: string) { diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 31fd9426f5..70ed83398d 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -107,9 +107,19 @@ {})} /> {:else if item.disabled && item.tooltip} -
- {@render menuItem(item)} + lets the hover reach it so the user learns why the item is disabled. The ⓘ sits + outside the disabled button, the only place it can receive a hover. --> +
+
+ {@render menuItem(item)} +
+
+ + {#snippet text()} + {item.tooltip} + {/snippet} + +
{:else} {@render menuItem(item)} diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index c57d41029c..09a56c427a 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -85,7 +85,8 @@ ['dark-indigo']: 'bg-indigo-700 text-indigo-100 dark:bg-indigo-800 dark:text-indigo-200', ['dark-orange']: 'bg-orange-700 text-orange-100 dark:bg-orange-800 dark:text-orange-200', ['dark-violet']: 'bg-violet-700 text-violet-100 dark:bg-violet-800 dark:text-violet-200', - transparent: 'bg-surface-accent-selected text-accent border-gray-400 dark:border-gray-500' + // Keeps the unselected chip's border, so selecting one does not change its height. + transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected' } const hovers: Partial> = { diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index 524817ba34..4b419536fc 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -29,6 +29,8 @@ * up confines it. Off by default: it moves the dialog out of its DOM position, so opt * in per call site rather than assuming every caller wants it. */ alwaysPortal?: boolean + /** For a notice with nothing to decide: the confirm button is the only one. */ + hideCancel?: boolean children?: Snippet onConfirmed?: () => void | Promise onCanceled?: () => void @@ -47,6 +49,7 @@ trashbin = false, zIndexClass = 'z-[9999]', alwaysPortal = false, + hideCancel = false, children, onConfirmed, onCanceled @@ -228,15 +231,17 @@ {/if} {confirmationText} - + {#if !hideCancel} + + {/if}
diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 886c14042b..4fd472729f 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -32,6 +32,9 @@ children?: import('svelte').Snippet<[any]> onOpen?: () => void onClose?: () => void + /** Asked before a click away or Escape closes it; resolving false keeps it open, e.g. to + * confirm discarding unsaved changes. */ + confirmClose?: () => boolean | Promise } let { @@ -42,9 +45,25 @@ minZIndex = 0, children, onOpen, - onClose + onClose, + confirmClose }: Props = $props() + // While the question is on screen, the keys and clicks are its own. + let confirming = false + async function requestClose() { + if (confirming) return + if (confirmClose) { + confirming = true + try { + if (!(await confirmClose())) return + } finally { + confirming = false + } + } + closeDrawer() + } + let offset = $state(untrack(() => initialOffset)) // Note: when a Modal with minZIndex is open, all disposables (including // already-open Drawers) are elevated. This is acceptable — relative @@ -115,7 +134,7 @@ const last = stack.val[stack.val.length - 1] if (last === id) { e.stopPropagation() - closeDrawer() + requestClose() } } @@ -125,7 +144,16 @@ if (open) { switch (event.key) { case 'Escape': + // The confirmation on screen answers this Escape itself, as its Cancel. + if (confirming) break if ((id == stack.val[stack.val.length - 1] || stack.val.length == 0) && !preventEscape) { + if (confirmClose) { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + requestClose() + break + } stack.val.pop() event.preventDefault() event.stopPropagation() diff --git a/frontend/src/lib/components/common/drawer/Drawer.svelte b/frontend/src/lib/components/common/drawer/Drawer.svelte index 76502735ca..45cf99c7be 100644 --- a/frontend/src/lib/components/common/drawer/Drawer.svelte +++ b/frontend/src/lib/components/common/drawer/Drawer.svelte @@ -28,6 +28,8 @@ positionClass?: string | undefined name?: string children?: import('svelte').Snippet<[any]> + /** Asked before a click away or Escape closes it; resolving false keeps it open. */ + confirmClose?: () => boolean | Promise } let { @@ -43,7 +45,8 @@ class: clazz = '', positionClass = undefined, name = undefined, - children: children_render + children: children_render, + confirmClose }: Props = $props() if (open === undefined) { @@ -146,6 +149,7 @@ onOpen={() => dispatch('open')} onClose={() => dispatch('close')} {preventEscape} + {confirmClose} > {#snippet children({ handleClickAway, zIndex, isTop })}
{#if info.editable} - info.owner, + (role) => { + // The select shows what the database says; a pick is a request, and only the + // applied change moves it. + if (role && role !== info.owner) { + confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`) + } } } - } - /> + bottomSnippet={manageRoles ? manageRolesEntry : undefined} + /> + {/snippet} + {@render withDisabledReason(ownerSelect, 'w-64')} {:else} {info.owner} {/if} @@ -168,75 +290,76 @@ : 'What each role may do here, beyond what it owns.'}
- {#if info.editable} - - confirm( - { type: 'grant', role, privileges, scope }, - `Granted ${privileges.join(', ')} to ${role}` - )} - /> + + {#if grantRows.length > 1 || roleFilters.length > 0 || privilegeFilters.length > 0} +
+ {#each roleChips as role (`role:${role}`)} + {@render filterChip(role, roleFilters.includes(role), () => { + roleFilters = toggled(roleFilters, role) + })} + {/each} + {#each privilegeChips as privilege (`privilege:${privilege}`)} + {@render filterChip(privilege, privilegeFilters.includes(privilege), () => { + privilegeFilters = toggled(privilegeFilters, privilege) + })} + {/each} +
{/if} - {#if grantRows.length === 0} - No grants yet. - {:else} - - - - Role - Privileges + + + + Role + Privileges + {#if showCoverage} On - - - - - {#each grantRows as grant (grantKey(grant))} - {@const revokeScope = revokeScopeOf(grant)} - {@const revocable = revocablePrivileges(grant, target)} - {@const blocked = blockingSources(grant, revocable)} - {@const uncovered = uncoveredCreators(grant, info.roles)} - - {grant.grantee} - {grant.privileges.join(', ')} - - {grantScopeLabel(grant)} - {#if blocked.length > 0} - - from {blocked.join(', ')} - - {/if} - {#if uncovered.length > 0} - - · not for what {uncovered.join(', ')} - {uncovered.length === 1 ? 'creates' : 'create'} - - {/if} + {/if} + + + + + {#each shownGrantRows as grant (grantKey(grant))} + {@const revokeScope = revokeScopeOf(grant)} + {@const revocable = revocablePrivileges(grant, target)} + {@const blocked = blockingSources(grant, revocable)} + {@const uncovered = uncoveredCreators(grant, info.roles)} + {@const unrevocable = unrevocableReason(grant, revokeScope, revocable, blocked)} + + {grant.grantee} + +
+ {#each grant.privileges as privilege (privilege)} + {@render tag(privilege)} + {/each} +
+ {#if blocked.length > 0 || uncovered.length > 0} +
+ {[ + blocked.length > 0 && `granted by ${blocked.join(', ')}`, + uncovered.length > 0 && + `not for what ${uncovered.join(', ')} ${uncovered.length === 1 ? 'creates' : 'create'}: a default privilege only covers the roles it was granted for` + ] + .filter(Boolean) + .join(' · ')} +
+ {/if} +
+ {#if showCoverage} + + {grantCoverage(grant, target) ?? targetLabel} - - - {#if info.editable && revokeScope && revocable.length > 0 && blocked.length === 0 && info.roles.includes(grant.grantee) && grant.grantee !== ADMIN_ROLE} + {/if} + + {#if info.editable} + {#snippet revokeButton()}
{/if} diff --git a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte index 5e5119abb6..745c1a1db5 100644 --- a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte +++ b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte @@ -1,17 +1,18 @@ -
-
- GRANT - ({ value: p, label: p }))} - placeholder="privileges" - {disabled} - size="sm" - class="min-w-48" - /> - ON + +
+ GRANT + ({ value: p, label: p }))} + placeholder="privileges" + {disabled} + size="md" + class="min-w-0 flex-[4]" + /> + + {#if scopes.length > 1} + ON ({ value: r, label: r }))} - placeholder="role" - {disabled} - size="sm" - class="w-40" - /> - -
- {#if statement} -
{statement}
{/if} + TO + - {#if info?.supported && permissioned && !hasUnsavedChanges} + {#if info?.supported && permissioned}
- + +
+ +
{/if} {/if} @@ -430,10 +506,7 @@
+ {/await} {/if} -{#if notExists} -
There was an error loading the app, is the url correct? Go to Windmill -
-{:else if noPermission} - {#if guestAppPath && !$userStore} -
Sign in to open this app
-
- You do not need a Windmill account. Signing in lets you open this app and nothing else. -
- {:else} -
- This app requires read access -
-
- {#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace} - You are logged in but are not a member of the workspace {effectiveWorkspace} this app is part of - {:else}You must be logged in and have read access to this app{/if}
- {/if} -
- {#if !jwtError} - - {/if} -
+{#if notExists || noPermission} + {#await Promise.all([loadAppCss(), import('./PublicAppAccess.svelte')]) then [, { default: PublicAppAccess }]} + + {/await} {:else if app} {#key app} {#if app.raw_app && effectiveWorkspace} - -
+ above it falls back to 150px. Styled inline: app.css may not be loaded here. --> +
} @@ -178,58 +118,28 @@ />
{:else if app.raw_app && !effectiveWorkspace} -
- - Unable to load raw app: workspace information is missing. - -
+ {#await Promise.all([loadAppCss(), import('$lib/components/common/alert/Alert.svelte')]) then [, { default: Alert }]} +
+ + Unable to load raw app: workspace information is missing. + +
+ {/await} {:else} -
- {#await loadAppPreview()} - - {:then Module} - goto(path)} - gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} - /> - {:catch} -
- Reload the page to try again. -
- {/await} -
+ {#await Promise.all([loadAppCss(), import('./PublicLowCodeApp.svelte')]) then [, { default: PublicLowCodeApp }]} + + {/await} {/if} {/key} {:else} - + {#await loadAppCss() then} + + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte new file mode 100644 index 0000000000..9a993b4972 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte @@ -0,0 +1,81 @@ + + +{#if notExists} +
There was an error loading the app, is the url correct? Go to Windmill +
+{:else} + {#if guestAppPath && !user} +
Sign in to open this app
+
+ You do not need a Windmill account. Signing in lets you open this app and nothing else. +
+ {:else} +
+ This app requires read access +
+
+ {#if user}You are logged in but have no read access to this app{:else if globalUser && workspace} + You are logged in but are not a member of the workspace {workspace} this app is part of + {:else}You must be logged in and have read access to this app{/if}
+ {/if} +
+ {#if !jwtError} + { + dropPrefetched() + onLoginSuccess() + }} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> + {/if} +
+{/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index 934511abce..dbb9b4b88b 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -23,24 +23,44 @@ * bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`. */ import { BROWSER } from 'esm-env' - import { OpenAPI, UserService } from '$lib/gen' + import { OpenAPI } from '$lib/gen/core/OpenAPI' import { page } from '$app/state' import { onDestroy, onMount, setContext, type Snippet } from 'svelte' - import Alert from '$lib/components/common/alert/Alert.svelte' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import { base } from '$app/paths' import { goto } from '$app/navigation' - import Login from '$lib/components/Login.svelte' - import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils' + import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils/queryParams' import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types' import { loadAppPreview } from './loadAppPreview' - import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte' + import { dropPrefetched, loadAppCss } from './publicAppApi' import { hasStoredSdkConsent, sdkConsentCovers, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes' + // Everything but a rendered app (sign-in, errors, the SDK consent prompt) loads on + // demand: a raw app that renders right away needs none of it. See publicAppApi.ts. + let frameUi: + | Promise<{ + Alert: typeof import('$lib/components/common/alert/Alert.svelte').default + Login: typeof import('$lib/components/Login.svelte').default + RawAppSdkConsent: typeof import('$lib/components/raw_apps/RawAppSdkConsent.svelte').default + }> + | undefined + function loadFrameUi() { + return (frameUi ??= Promise.all([ + loadAppCss(), + import('$lib/components/common/alert/Alert.svelte'), + import('$lib/components/Login.svelte'), + import('$lib/components/raw_apps/RawAppSdkConsent.svelte') + ]).then(([, alert, login, consent]) => ({ + Alert: alert.default, + Login: login.default, + RawAppSdkConsent: consent.default + }))) + } + type EmbedToken = { token?: string | null raw_app?: boolean @@ -235,7 +255,8 @@ if (offerSignIn && accountSession === 'unknown') { // Workspace-less, so it answers for an account and never for a guest // (pinned to its workspace) or for nobody. - UserService.getCurrentEmail() + import('$lib/gen') + .then(({ UserService }) => UserService.getCurrentEmail()) .then(() => (accountSession = 'held')) .catch(() => (accountSession = 'none')) } @@ -249,8 +270,12 @@ let staleGuestLogoutFailed = $state(false) $effect(() => { if (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared) { - UserService.logout() - .then(() => (staleGuestCleared = true)) + import('$lib/gen') + .then(({ UserService }) => UserService.logout()) + .then(() => { + dropPrefetched() + staleGuestCleared = true + }) .catch(() => (staleGuestLogoutFailed = true)) } }) @@ -427,7 +452,10 @@ // Render the app directly on this origin: same-origin when unsandboxed // (the default), or a single opaque bundle iframe when it's a sandboxed // raw app. A low-code app's runtime downloads alongside the app payload. - if (!isRaw) loadAppPreview().catch(() => {}) + if (!isRaw) { + loadAppCss().catch(() => {}) + loadAppPreview().catch(() => {}) + } onViewerReady?.(undefined, requestTokenRefresh) } else { // Sandboxed low-code: hand the scoped token to the opaque viewer iframe. @@ -537,6 +565,7 @@ onMount(() => { if (isViewer) { // Only a sandboxed low-code app is ever framed as a viewer. + loadAppCss().catch(() => {}) loadAppPreview().catch(() => {}) window.addEventListener('message', handleViewerMessage) installHashRelay() @@ -569,105 +598,120 @@ {#if viewerReady} {@render viewer()} {:else if viewerOrphaned} -
- - This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own - page, use the app's public URL without the wm_embed parameter. - -
+ {#await loadFrameUi() then { Alert }} +
+ + This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your + own page, use the app's public URL without the wm_embed parameter. + +
+ {/await} {:else} - + {#await loadAppCss() then} + + {/await} {/if} {:else if status === 'loading'} - -{:else if status === 'notExists' && !offerSignIn} -
- - There was an error loading the app, is the url correct? - Go to Windmill - -
-{:else if status === 'sdkPrompt'} - - -{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} - -{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} -
- - The app could not be reached to find out who may open it. Reload to try again. - -
-{:else if offerSignIn} - - {#if signInDidNotHelp} - -
- You are signed in, but this app is not open to you -
-
- It is open to the people it was shared with{guestAppPath - ? ', and to guests who have no Windmill account' - : ''}. Ask the person who shared it to give your account access. -
+ {#await loadAppCss() then} + + {/await} +{:else if status === 'ready'} + {#if unsandboxed} + + {@render viewer()} + {:else if isRaw} + + {@render viewer()} {:else} - {#if guestAppPath} -
Sign in to open this app
-
- You do not need a Windmill account. Signing in lets you open this app and nothing else. -
- {:else} -
- This app requires read access -
- {/if} - {#if signInError} -
- {signInError} -
- {/if} -
- { - signInError = undefined - accountSession = 'unknown' - initEmbedder() - }} - onLoginError={(message) => (signInError = message)} - popup - guestApp={guestAppPath} - rd={page.url.pathname + page.url.search + page.url.hash} - /> -
+ + {/if} -{:else if unsandboxed} - - {@render viewer()} -{:else if isRaw} - - {@render viewer()} {:else} - - + {#await loadFrameUi() then { Alert, Login, RawAppSdkConsent }} + {#if status === 'notExists' && !offerSignIn} +
+ + There was an error loading the app, is the url correct? + Go to Windmill + +
+ {:else if status === 'sdkPrompt'} + + + {:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} + + {:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} +
+ + The app could not be reached to find out who may open it. Reload to try again. + +
+ {:else if offerSignIn} + + {#if signInDidNotHelp} + +
+ You are signed in, but this app is not open to you +
+
+ It is open to the people it was shared with{guestAppPath + ? ', and to guests who have no Windmill account' + : ''}. Ask the person who shared it to give your account access. +
+ {:else} + {#if guestAppPath} +
Sign in to open this app
+
+ You do not need a Windmill account. Signing in lets you open this app and nothing else. +
+ {:else} +
+ This app requires read access +
+ {/if} + {#if signInError} +
+ {signInError} +
+ {/if} +
+ { + dropPrefetched() + signInError = undefined + accountSession = 'unknown' + initEmbedder() + }} + onLoginError={(message) => (signInError = message)} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> +
+ {/if} + {/if} + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte new file mode 100644 index 0000000000..f74be67166 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte @@ -0,0 +1,91 @@ + + +
+ {#await loadAppPreview()} + + {:then Module} + goto(path)} + gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} + /> + {:catch} +
+ Reload the page to try again. +
+ {/await} +
diff --git a/frontend/src/lib/components/apps/editor/publicAppApi.ts b/frontend/src/lib/components/apps/editor/publicAppApi.ts new file mode 100644 index 0000000000..674cb0b622 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/publicAppApi.ts @@ -0,0 +1,82 @@ +// The public app routes (`/public/...`, `/a/...`) render a raw app as little more than its +// bundle's iframe, so they must not statically import the generated client, `$lib/stores` +// or `$lib/utils`: each drags in most of the app shell. `vite.config.js` +// (`assertLeanPublicAppRoutes`) fails the build if they do. Anything heavier is imported +// lazily by the branch that needs it. +import { OpenAPI } from '$lib/gen/core/OpenAPI' +import type { AppWithLastVersion, User } from '$lib/gen/types.gen' +import type { UserExt } from '$lib/stores' +import { mapUserToUserExt } from '$lib/userExt' + +export type PublicAppValue = AppWithLastVersion & { value: any; workspace_id?: string } + +declare global { + interface Window { + /** Responses `app.html` requested before any module loaded, keyed by URL. */ + __wmPublicAppPrefetch?: Record> + } +} + +/** Carries `status` like the generated client's `ApiError`, which callers branch on. */ +export class PublicApiError extends Error { + constructor(public status: number) { + super(`Request failed with status ${status}`) + } +} + +/** Prefetched responses answer for the credential the page loaded with; call this when it + * changes (a sign-in or logout on the page) so later reads go to the network. */ +export function dropPrefetched(): void { + delete window.__wmPublicAppPrefetch +} + +function takePrefetched(url: string): Promise | undefined { + const prefetched = window.__wmPublicAppPrefetch + const res = prefetched?.[url] + if (res) delete prefetched![url] + return res +} + +/** GET an API path with the page credential: the bearer token in `OpenAPI.TOKEN` when the + * share link carries one, the session cookie otherwise. A prefetched response is only + * taken in the cookie case, which is the only credential `app.html` sends. */ +export async function publicGet(path: string): Promise { + const url = `${OpenAPI.BASE}${path}` + const token = typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined + const res = await ((!token && takePrefetched(url)) || + fetch(url, { + // As the generated client does: the opaque viewer frame is cross-origin to the + // API, whose `Access-Control-Allow-Origin: *` refuses a credentialed request. + credentials: OpenAPI.WITH_CREDENTIALS ? OpenAPI.CREDENTIALS : undefined, + headers: token ? { Authorization: `Bearer ${token}` } : {} + })) + if (!res.ok) throw new PublicApiError(res.status) + return (res.headers.get('content-type')?.includes('json') ? res.json() : res.text()) as T +} + +export async function getUserExtOrUndefined(workspace: string): Promise { + try { + return mapUserToUserExt( + await publicGet(`/w/${encodeURI(workspace)}/users/whoami`), + workspace + ) + } catch { + return undefined + } +} + +/** Only fades the "Powered by" badge, so a failure is not worth surfacing. */ +export async function getLicenseOrUndefined(): Promise { + try { + return (await publicGet('/ee_license')) || undefined + } catch { + return undefined + } +} + +let appCss: Promise | undefined +/** The app-wide stylesheet, which these routes need only off the raw-app path: sign-in, + * errors, skeletons and low-code apps. A raw app's bundle brings its own styles. */ +export function loadAppCss(): Promise { + return (appCss ??= import('$lib/assets/app.css')) +} diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index c3eadc1f68..9f675664b1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -52,6 +52,11 @@ // Job ids launched by this app instance — see `gateJobIds`. const launchedJobs = new Set() + /** For a host that loads this component after the bundle may already have sent requests. */ + export function handleMessage(event: MessageEvent) { + return listener(event) + } + let listener = async (event) => { // Only accept messages from the bundle iframe (opaque origin) or the // detached preview window we opened, so other frames/extensions can't diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 8a79b4b8f9..9eced2d0e6 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -1,6 +1,6 @@ - +{#await runnerModule then { default: Runner }} + +{:catch} + {#if requestRefused} + +
+ Could not load the app runtime. Reload the page to try again. +
+ {/if} +{/await} {#if iframeSrc} + {/if} diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 006c2ab60c..12d6f237ba 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -1,5 +1,6 @@ -import { type User, UserService } from '$lib/gen' +import { UserService } from '$lib/gen' import type { UserExt } from './stores.js' +import { mapUserToUserExt } from './userExt' async function fetchUserExt(workspace: string): Promise { return mapUserToUserExt(await UserService.whoami({ workspace }), workspace) @@ -42,16 +43,3 @@ export function getWorkspaceRole(workspace: string): Promise { export function clearWorkspaceRoleCache(): void { workspaceRoleCache.clear() } - -function mapUserToUserExt(user: User, workspace: string): UserExt { - const ext: UserExt = { - ...user, - workspace_id: workspace, - groups: user.groups!, - pgroups: user.groups!.map((x) => `g/${x}`) - } - if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) { - ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined - } - return ext -} diff --git a/frontend/src/lib/userExt.ts b/frontend/src/lib/userExt.ts new file mode 100644 index 0000000000..d06e1cd04d --- /dev/null +++ b/frontend/src/lib/userExt.ts @@ -0,0 +1,15 @@ +import type { User } from '$lib/gen/types.gen' +import type { UserExt } from './stores' + +export function mapUserToUserExt(user: User, workspace: string): UserExt { + const ext: UserExt = { + ...user, + workspace_id: workspace, + groups: user.groups!, + pgroups: user.groups!.map((x) => `g/${x}`) + } + if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) { + ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined + } + return ext +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index ec112607fd..8540b964e8 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1322,28 +1322,7 @@ export function isCodeInjection(expr: string | undefined): boolean { return dynamicTemplateRegex.test(expr) } -// Query params Windmill consumes internally and that should not be exposed to -// app logic via the `query` context. Only params we actually own are listed -// here — the `wm_` prefix is a naming convention, not a reserved namespace, so -// we don't strip it wholesale (that would break apps reading their own `wm_*` -// params). `wm_coep` is a transport flag for cross-origin isolation headers; -// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params -// (see PublicAppFrame). -export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin']) - -export function urlParamsToObject( - params: URLSearchParams, - opts?: { stripReserved?: boolean } -): Record { - const result: Record = {} - params.forEach((value, key) => { - if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { - return - } - result[key] = value - }) - return result -} +export { WINDMILL_RESERVED_QUERY_PARAMS, urlParamsToObject } from './utils/queryParams' export async function tryEvery({ tryCode, diff --git a/frontend/src/lib/utils/queryParams.ts b/frontend/src/lib/utils/queryParams.ts new file mode 100644 index 0000000000..ffefc47eea --- /dev/null +++ b/frontend/src/lib/utils/queryParams.ts @@ -0,0 +1,22 @@ +// Query params Windmill consumes internally and that should not be exposed to +// app logic via the `query` context. Only params we actually own are listed +// here — the `wm_` prefix is a naming convention, not a reserved namespace, so +// we don't strip it wholesale (that would break apps reading their own `wm_*` +// params). `wm_coep` is a transport flag for cross-origin isolation headers; +// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params +// (see PublicAppFrame). +export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin']) + +export function urlParamsToObject( + params: URLSearchParams, + opts?: { stripReserved?: boolean } +): Record { + const result: Record = {} + params.forEach((value, key) => { + if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { + return + } + result[key] = value + }) + return result +} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte index 75e31a9a00..c9623bc340 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte @@ -1,4 +1,6 @@ + +{@render children?.()} diff --git a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte similarity index 99% rename from frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte rename to frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte index 85a72dd7e3..9b3a9c1e41 100644 --- a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte @@ -93,6 +93,7 @@ + // app.css is imported by the layouts below this one, not here: the public app routes + // (`public/`, `a/`) load it only when they render more than a raw app's bundle. + // A production build links a layout's CSS ahead of its page's, but in dev a stylesheet + // lands when its module evaluates, and a page's can evaluate before its layout's. This + // module is imported at startup, so loading it here in dev keeps app.css first. + if (import.meta.env.DEV && !/^\/(public|a)\//.test(location.pathname)) { + import('$lib/assets/app.css') + } + + -
+ body in `display: contents`), and the viewer's own boxes are full-height. Styled + inline: a raw app renders here without app.css. --> +
import { BROWSER } from 'esm-env' - import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen' - import { userStore } from '$lib/stores' - - import { setLicense } from '$lib/enterpriseUtils' - - import { getUserExt } from '$lib/user' + import { OpenAPI } from '$lib/gen/core/OpenAPI' + import type { GuestEntry } from '$lib/gen/types.gen' + import type { UserExt } from '$lib/stores' import { page } from '$app/state' import { base } from '$lib/base' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' + import { + getLicenseOrUndefined, + getUserExtOrUndefined, + publicGet, + type PublicAppValue + } from '$lib/components/apps/editor/publicAppApi' - let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined) + let app: PublicAppValue | undefined = $state(undefined) + let user: UserExt | undefined = $state(undefined) + let license: string | undefined = $state(undefined) let notExists = $state(false) let noPermission = $state(false) let jwtError = $state(false) @@ -70,35 +75,21 @@ } else if (parsedSecret.jwt) { OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt } - const headers: Record = {} - if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) { - headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}` - } const consent = opts?.sdkConsent ? '?sdk_consent=true' : '' - const res = await fetch( - `${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`, - { headers } - ) - if (!res.ok) { - const err: any = new Error('Failed to fetch embed token') - err.status = res.status - throw err - } - return await res.json() + return await publicGet(`/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`) } // Viewer side: load the app + user using the embed token handed to the iframe. async function loadApp() { + // Neither waits on the other: `app.html` already requested both. + const userLoad = getUserExtOrUndefined(workspace) + const appLoad = publicGet( + `/w/${workspace}/apps_u/public_app/${encodeURI(parsedSecret.secret)}` + ) + appLoad.catch(() => {}) + user = await userLoad try { - userStore.set(await getUserExt(workspace)) - } catch (e) { - console.warn('Anonymous user') - } - try { - app = await AppService.getPublicAppBySecret({ - workspace, - path: parsedSecret.secret - }) + app = await appLoad noPermission = false notExists = false } catch (e) { @@ -126,7 +117,9 @@ if (guestEntrySettled()) return for (let attempt = 0; attempt < 3; attempt++) { try { - const entry = await AppService.getGuestEntry({ workspace, path: parsedSecret.secret }) + const entry = await publicGet( + `/w/${workspace}/apps_u/guest_entry/${encodeURI(parsedSecret.secret)}` + ) guestAppPath = `${workspace}/${entry.app_path}` guestEntry = 'guest' return @@ -156,13 +149,14 @@ } if (BROWSER) { - setLicense() + getLicenseOrUndefined().then((l) => (license = l)) } -
+ body in `display: contents`), and the viewer's own boxes are full-height. Styled + inline: a raw app renders here without app.css. --> +
Object.keys(bundle).some((file) => file.startsWith('_app/immutable/')) /** - * Fail the build if a public app URL statically loads the low-code runtime or monaco. + * Fail the build if a public app URL statically loads more than a raw app needs. * * These pages also serve raw apps, which only need a small shell around their bundle's - * iframe. One static import of AppPreview (or of anything reaching monaco) makes every - * public app page preload ~650 chunks instead of ~55. See loadAppPreview.ts. + * iframe, and the page shows nothing until its static imports have loaded. One static + * import of AppPreview (or of anything reaching monaco) makes every public app page + * preload ~650 chunks; one of the generated client, `$lib/stores` or `$lib/utils` drags + * in most of the app shell. The root layout wraps these pages, so it must not import + * app.css either. See loadAppPreview.ts and publicAppApi.ts. */ function assertLeanPublicAppRoutes() { // Route directories, so +page.js counts as well as +page.svelte. const routes = ['/src/routes/public/[workspace]/[...secret]/', '/src/routes/a/[...path]/'] const forbidden = [ '/src/lib/components/apps/editor/AppPreview.svelte', - '/node_modules/monaco-editor/' + '/node_modules/monaco-editor/', + '/src/lib/assets/app.css', + '/src/lib/gen/services.gen.ts', + '/src/lib/stores.ts', + '/src/lib/utils.ts', + '/src/lib/components/Login.svelte', + '/node_modules/@melt-ui/' ] return { name: 'wm-assert-lean-public-app-routes', generateBundle(_options, bundle) { if (!isClientBuild(bundle)) return - for (const route of routes) { - assertStaticClosureExcludes(this, bundle, route, forbidden, 'loadAppPreview.ts') + for (const route of [...routes, '/src/routes/+layout.svelte']) { + assertStaticClosureExcludes(this, bundle, route, forbidden, 'publicAppApi.ts') } } } @@ -372,7 +381,19 @@ const config = { // which makes the *chunk* graph cyclic where the module graph is not, and // modules then evaluate against uninitialized bindings. See // docs/frontend-import-cycles.md. - advancedChunks: { groups: [{ name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ }] } + // The client's config is split out for the public app routes, which set the + // credential without loading the client. It imports nothing at runtime, so + // its chunk cannot be part of a cycle. + advancedChunks: { + groups: [ + { + name: 'gen-config', + test: /[\\/]src[\\/]lib[\\/]gen[\\/]core[\\/]OpenAPI\.ts$/, + priority: 1 + }, + { name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ } + ] + } } } }, From 8b9222d778c3d9cf1f842f10b40a53afff6c8d52 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 09:15:49 +0200 Subject: [PATCH 106/174] chore: fix rust client retry param and ai-evals workflow yaml --- .github/workflows/ai-evals-scheduled.yml | 2 +- rust-client/src/client.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index 94ecc7c6c8..580a757c21 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -15,7 +15,7 @@ on: description: "Space-separated modes (global, cli, flow, app, script)" default: "global cli" models: - description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"" + description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for" default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro" runs: description: "Runs per case" diff --git a/rust-client/src/client.rs b/rust-client/src/client.rs index 451dc58d85..bf47e7e95a 100644 --- a/rust-client/src/client.rs +++ b/rust-client/src/client.rs @@ -785,6 +785,7 @@ impl Windmill { None, None, None, + None, ) .await? } else { @@ -808,6 +809,7 @@ impl Windmill { None, None, None, + None, ) .await? }; From ff028babe88c300f2ca8de7a216f6eae5b6989cc Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:30:37 +0200 Subject: [PATCH 107/174] close raw app preview pop-out when the editor tab reloads or closes (#11607) --- .../lib/components/raw_apps/RawAppEditor.svelte | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index d0d1b40de1..f708b97efe 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -1839,13 +1839,15 @@ addEventListener('message', function (e) { syncExternalPreview() } - onDestroy(() => { - // Don't leave a detached preview behind when the editor unmounts: it - // would stop receiving builds and, once refreshed, has no opener to - // re-feed it — a permanently blank orphan. + // Don't leave a detached preview behind when the editor goes away, by + // unmounting or by its tab reloading or closing (`pagehide`, which skips + // `onDestroy`): it would stop receiving builds, and its document is a blob + // URL owned by this page, so refreshing it gives "not found". + function closeExternalPreview() { if (externalPreviewWindow && !externalPreviewWindow.closed) externalPreviewWindow.close() externalPreviewWindow = null - }) + } + onDestroy(closeExternalPreview) function openExternalPreview() { // Reuse an already-open window instead of spawning duplicates. @@ -2512,7 +2514,7 @@ addEventListener('message', function (e) { } - + { From d5059166099f9e7542ec6f3caf95ccb0f2c605a9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 09:44:54 +0200 Subject: [PATCH 108/174] chore(main): release 1.828.0 (#11611) * chore(main): release 1.828.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 23 +++ backend/Cargo.lock | 178 +++++++++--------- 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, 159 insertions(+), 136 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 62ac9abafe..cc72934156 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.827.0" + ".": "1.828.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b157fa61d..c958457c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.828.0](https://github.com/windmill-labs/windmill/compare/v1.827.0...v1.828.0) (2026-10-08) + + +### Features + +* add gpt-6.1-sol to the chat, agent steps and weekly ai evals ([#11584](https://github.com/windmill-labs/windmill/issues/11584)) ([8f7e0c7](https://github.com/windmill-labs/windmill/commit/8f7e0c72848b56fa97f6f0141d930c7d15044864)) +* add wmill user list and document post-suspend approvers context ([#11600](https://github.com/windmill-labs/windmill/issues/11600)) ([1c2667c](https://github.com/windmill-labs/windmill/commit/1c2667cd5530925893cf69fd622938b6ffa55d1d)) +* ask before minting the frontend sdk token in the raw app editor ([#11604](https://github.com/windmill-labs/windmill/issues/11604)) ([6e948b0](https://github.com/windmill-labs/windmill/commit/6e948b07d13b85e9251c3a200bf149f167649bc4)) +* improve data table roles and grants UX ([#11562](https://github.com/windmill-labs/windmill/issues/11562)) ([d7d18f0](https://github.com/windmill-labs/windmill/commit/d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6)) +* one page header across routes, and one sidebar for operators and developers ([#11310](https://github.com/windmill-labs/windmill/issues/11310)) ([41162b2](https://github.com/windmill-labs/windmill/commit/41162b270c70ddac6a256bde9acf31a629990e24)) + + +### Bug Fixes + +* add retry to runScriptByPathAsync and runScriptByHashAsync ([#11485](https://github.com/windmill-labs/windmill/issues/11485)) ([3f3b1c2](https://github.com/windmill-labs/windmill/commit/3f3b1c2c308ddaba377db45308711e7c660d8775)) +* describe exponential retry as multiplier × seconds^attempt ([#11546](https://github.com/windmill-labs/windmill/issues/11546)) ([a2e51cd](https://github.com/windmill-labs/windmill/commit/a2e51cdbaafb7d71b8e8bbd818e40c472098f9ba)) +* keep capturing job logs after a non-UTF-8 output line ([#11595](https://github.com/windmill-labs/windmill/issues/11595)) ([0e7d974](https://github.com/windmill-labs/windmill/commit/0e7d974ce9b468353ecb6618dace710704690687)) + + +### Performance Improvements + +* load public raw apps without the loading screen ([#11608](https://github.com/windmill-labs/windmill/issues/11608)) ([c09764f](https://github.com/windmill-labs/windmill/commit/c09764fa6516badddbdbb520af32e5d1541cf996)) + ## [1.827.0](https://github.com/windmill-labs/windmill/compare/v1.826.0...v1.827.0) (2026-10-07) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index aeb7670d17..b5f3d9a306 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -6685,9 +6685,9 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] name = "inventory" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" dependencies = [ "rustversion", ] @@ -9617,7 +9617,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.15+spec-1.1.0", + "toml_edit 0.25.16+spec-1.1.0", ] [[package]] @@ -13622,9 +13622,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -13644,21 +13644,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.16+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722" dependencies = [ "indexmap 2.14.2", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333" dependencies = [ "winnow 1.0.4", ] @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.827.0" +version = "1.828.0" dependencies = [ "async-stream", "async-trait", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "argon2", @@ -15105,7 +15105,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15145,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15171,7 +15171,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.827.0" +version = "1.828.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15198,7 +15198,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15220,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15259,7 +15259,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15303,7 +15303,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -15353,7 +15353,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15379,7 +15379,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15407,7 +15407,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15429,7 +15429,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15449,7 +15449,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15491,7 +15491,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15520,7 +15520,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.827.0" +version = "1.828.0" dependencies = [ "lazy_static", "serde", @@ -15532,7 +15532,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.827.0" +version = "1.828.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15556,7 +15556,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15570,7 +15570,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15609,7 +15609,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.827.0" +version = "1.828.0" dependencies = [ "chrono", "lazy_static", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15643,7 +15643,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.827.0" +version = "1.828.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15757,7 +15757,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.827.0" +version = "1.828.0" dependencies = [ "chrono", "futures", @@ -15777,7 +15777,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.827.0" +version = "1.828.0" dependencies = [ "regex", "serde", @@ -15794,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15821,7 +15821,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "futures", @@ -15838,7 +15838,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.827.0" +version = "1.828.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15854,7 +15854,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -15875,7 +15875,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -15906,7 +15906,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "arc-swap", @@ -15931,7 +15931,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-stream", @@ -15967,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "futures", @@ -15985,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.827.0" +version = "1.828.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15994,7 +15994,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16006,7 +16006,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16018,7 +16018,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "gosyn", @@ -16030,7 +16030,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16042,7 +16042,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16054,7 +16054,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "nu-parser", @@ -16065,7 +16065,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16076,7 +16076,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16088,7 +16088,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16099,7 +16099,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16121,7 +16121,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16133,7 +16133,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16147,7 +16147,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16164,7 +16164,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16177,7 +16177,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde", @@ -16189,7 +16189,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16207,7 +16207,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16223,7 +16223,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16239,7 +16239,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16253,7 +16253,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16292,7 +16292,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "const_format", @@ -16332,7 +16332,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.827.0" +version = "1.828.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16343,7 +16343,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16378,7 +16378,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16402,7 +16402,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16435,7 +16435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16462,7 +16462,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16495,7 +16495,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16515,7 +16515,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16549,7 +16549,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16586,7 +16586,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16609,7 +16609,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16633,7 +16633,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -16657,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16692,7 +16692,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16720,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16745,7 +16745,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16764,7 +16764,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-once-cell", @@ -16883,7 +16883,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.827.0" +version = "1.828.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 06fdc001f0..e0b97275b2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.827.0" +version = "1.828.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.827.0" +version = "1.828.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 99d82bb43b..1bdbdd6097 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.827.0" +version = "1.828.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.827.0" +version = "1.828.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.827.0" +version = "1.828.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 9ac7f81c7f..b2fd850217 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.827.0" +version = "1.828.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d8c217cbc8..9173eae28c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.827.0 + version: 1.828.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 281ba460b6..df2044d419 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.827.0"; +export const VERSION = "v1.828.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 692e5d33f5..c6691e0048 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.827.0", + "version": "1.828.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 287636a9f8..a119b8645d 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.827.0", + "version": "1.828.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 d05c4e60a7..860a3ddede 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.827.0"; +export const VERSION = "1.828.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f5ea5b721..28ecbebf42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 4515459ba3..cc42f5424a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.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 4509187f28..14c2eb8fac 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.827.0" +wmill = ">=1.828.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0fcf8a61dd..57f1fc5648 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.827.0 + version: 1.828.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 62cf4298d6..927edd49f6 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.827.0' + ModuleVersion = '1.828.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 4ffcbddd89..13ac4753c5 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.827.0" +version = "1.828.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 615e7885eb..45306830f5 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.827.0", + "version": "1.828.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 59f4b98719..98caf3895a 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.827.0", + "version": "1.828.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 35d501b3a8..b08184bb19 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.827.0 +1.828.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 295ba3e97a..064996f393 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.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.827.0", + "version": "1.828.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 05ba30630f..7e8c7a641d 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.827.0", + "version": "1.828.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 588d8463dfa1c8936b8753b5f0fd794ecb115203 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 12:56:33 +0200 Subject: [PATCH 109/174] feat: let a job see that its worker is draining (#11612) * feat: let a job see that its worker is draining * fix: gate worker draining check on job visibility and export sdk helper * fix: keep draining across re-registration and ignore parked jobs * fix: reset draining only on worker startup registration * chore: update ee-repo-ref to 55feb2e84332179faa4e17aeb419f7f480df79a6 This commit updates the EE repository reference after PR #857 was merged in windmill-ee-private. Previous ee-repo-ref: e5010fa046a3aba93595d1bf44eb6a0cef2b5273 New ee-repo-ref: 55feb2e84332179faa4e17aeb419f7f480df79a6 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- ...3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json | 23 +++++++++ ...f7d4b418a9e82be56dfe7dd65d160e0dcfd13.json | 24 +++++++++ ...f20b6971b993e0671b6ccb45db5ef028550bc.json | 33 ------------ ...123a98ec175f9d3dc72e40a1cb40e14dec919.json | 14 +++++ ...64360a5d5c783d6ca0a3c4123ea126ed0e60b.json | 34 +++++++++++++ ...ceec2b6f1ae1a32fcf375161d04419404d9c.json} | 10 +++- backend/ee-repo-ref.txt | 2 +- ...261008070335_worker_ping_draining.down.sql | 1 + ...20261008070335_worker_ping_draining.up.sql | 1 + backend/summarized_schema.txt | 2 +- backend/tests/worker_ping_ip.rs | 32 +++++++++++- backend/windmill-api-auth/src/scopes.rs | 1 + .../tests/jobs_authed.rs | 48 +++++++++++++++++ backend/windmill-api-workers/src/lib.rs | 3 +- backend/windmill-api/openapi.yaml | 24 +++++++++ backend/windmill-api/src/jobs.rs | 51 +++++++++++++++++++ backend/windmill-common/src/worker.rs | 24 ++++++++- backend/windmill-worker/src/common.rs | 1 + backend/windmill-worker/src/worker.rs | 31 +++++++++-- backend/windmill-worker/src/worker_utils.rs | 50 ++++++++++++++++-- cli/src/guidance/skills.gen.ts | 36 +++++++++++++ .../(root)/(logged)/workers/+page.svelte | 11 +++- python-client/wmill/wmill/client.py | 27 ++++++++++ system_prompts/auto-generated/prompts.ts | 20 ++++++++ system_prompts/auto-generated/script.md | 20 ++++++++ system_prompts/auto-generated/sdks/python.md | 12 +++++ .../auto-generated/sdks/typescript.md | 8 +++ .../skills/write-script-bun/SKILL.md | 8 +++ .../skills/write-script-bunnative/SKILL.md | 8 +++ .../skills/write-script-deno/SKILL.md | 8 +++ .../skills/write-script-python3/SKILL.md | 12 +++++ typescript-client/build.jsr.sh | 2 +- typescript-client/build.sh | 4 +- typescript-client/client.ts | 13 +++++ 34 files changed, 548 insertions(+), 50 deletions(-) create mode 100644 backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json create mode 100644 backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json delete mode 100644 backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json create mode 100644 backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json create mode 100644 backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json rename backend/.sqlx/{query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json => query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json} (86%) create mode 100644 backend/migrations/20261008070335_worker_ping_draining.down.sql create mode 100644 backend/migrations/20261008070335_worker_ping_draining.up.sql diff --git a/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json b/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json new file mode 100644 index 0000000000..036610e08c --- /dev/null +++ b/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "draining", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b" +} diff --git a/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json b/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json new file mode 100644 index 0000000000..1c8dfcfa57 --- /dev/null +++ b/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wp.draining FROM v2_job j\n JOIN v2_job_queue q ON q.id = j.id\n JOIN worker_ping wp ON wp.worker = q.worker\n WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL\n AND ($3::text[] IS NULL OR j.tag = ANY($3))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "draining", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13" +} diff --git a/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json b/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json deleted file mode 100644 index 7f6fe7a81c..0000000000 --- a/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "jobs_executed", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - "TextArray", - "Varchar", - "Varchar", - "TextArray", - "Varchar", - "Int8", - "Int8", - "Text", - "Bool" - ] - }, - "nullable": [ - false - ] - }, - "hash": "c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc" -} diff --git a/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json b/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json new file mode 100644 index 0000000000..9ebf9289c2 --- /dev/null +++ b/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE worker_ping SET draining = true WHERE worker = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919" +} diff --git a/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json b/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json new file mode 100644 index 0000000000..9f51c69616 --- /dev/null +++ b/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining)\n RETURNING jobs_executed", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jobs_executed", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "TextArray", + "Varchar", + "Varchar", + "TextArray", + "Varchar", + "Int8", + "Int8", + "Text", + "Bool", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b" +} diff --git a/backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json b/backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json similarity index 86% rename from backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json rename to backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json index 583d2bcc6b..5ee5c7571d 100644 --- a/backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json +++ b/backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3", + "query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -107,6 +107,11 @@ "ordinal": 20, "name": "native_mode", "type_info": "Bool" + }, + { + "ordinal": 21, + "name": "draining", + "type_info": "Bool" } ], "parameters": { @@ -138,8 +143,9 @@ true, true, true, + false, false ] }, - "hash": "68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca" + "hash": "fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 5a36bc5e61..ff44a56995 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -e5689bf58a125ff46aabb6b5d0f030a7e5c715d3 +55feb2e84332179faa4e17aeb419f7f480df79a6 diff --git a/backend/migrations/20261008070335_worker_ping_draining.down.sql b/backend/migrations/20261008070335_worker_ping_draining.down.sql new file mode 100644 index 0000000000..64a5f2949d --- /dev/null +++ b/backend/migrations/20261008070335_worker_ping_draining.down.sql @@ -0,0 +1 @@ +ALTER TABLE worker_ping DROP COLUMN IF EXISTS draining; diff --git a/backend/migrations/20261008070335_worker_ping_draining.up.sql b/backend/migrations/20261008070335_worker_ping_draining.up.sql new file mode 100644 index 0000000000..33b5ad5e3e --- /dev/null +++ b/backend/migrations/20261008070335_worker_ping_draining.up.sql @@ -0,0 +1 @@ +ALTER TABLE worker_ping ADD COLUMN IF NOT EXISTS draining BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 2a518a7cc8..284a822b47 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -219,7 +219,7 @@ websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), work windmill_migrations: name(text), created_at(ts) worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint) FK: (workspace_id) -> workspace(id) -worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]) +worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]), draining(bool) workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char), is_dev_workspace(bool) FK: (parent_workspace_id) -> workspace(id) workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts) diff --git a/backend/tests/worker_ping_ip.rs b/backend/tests/worker_ping_ip.rs index 772f4193c3..2421e888ef 100644 --- a/backend/tests/worker_ping_ip.rs +++ b/backend/tests/worker_ping_ip.rs @@ -2,10 +2,19 @@ use sqlx::{Pool, Postgres}; use uuid::Uuid; use windmill_common::{ external_ip::UNKNOWN_IP, - worker::{insert_ping_query, update_worker_ping_main_loop_query}, + worker::{insert_ping_query, set_worker_draining_query, update_worker_ping_main_loop_query}, }; async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> anyhow::Result<()> { + register(db, worker, ip, None).await +} + +async fn register( + db: &Pool, + worker: &str, + ip: Option<&str>, + draining: Option, +) -> anyhow::Result<()> { insert_ping_query( "test-instance", worker, @@ -19,12 +28,33 @@ async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> any None, None, false, + draining, db, ) .await?; Ok(()) } +/// A worker re-registers mid-life once its external IP resolves. That registration must not +/// clear a draining flag the same process already published; only a worker starting up under +/// a reclaimed name resets it. +#[sqlx::test] +async fn only_a_starting_worker_resets_draining(db: Pool) -> anyhow::Result<()> { + let draining = || async { + sqlx::query_scalar::<_, bool>("SELECT draining FROM worker_ping WHERE worker = 'wk-drain'") + .fetch_one(&db) + .await + }; + register(&db, "wk-drain", None, Some(false)).await?; + set_worker_draining_query("wk-drain", &db).await?; + register(&db, "wk-drain", Some("1.2.3.4"), None).await?; + assert!(draining().await?); + + register(&db, "wk-drain", None, Some(false)).await?; + assert!(!draining().await?); + Ok(()) +} + /// The external IP resolves in the background, so the initial ping often has none yet. That must /// not blank the address a previous process wrote to the row this one reclaims — worker names are /// stable across restarts under EXIT_AFTER_N_JOBS. diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index dce6a0ff72..b636e11a1c 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -996,6 +996,7 @@ pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uui ["job_metrics", "set_progress", ..] => (http_method == "POST", 2), ["job_metrics", "get_progress", ..] => (http_method == "GET", 2), ["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls", ..] => (http_method == "GET", 2), + ["jobs", "worker_is_draining", ..] => (http_method == "GET", 2), ["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3), ["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3), _ => return false, diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index c3da2f3fb9..2c5d2ee78e 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -702,3 +702,51 @@ async fn test_list_filtered_job_uuids_resolved_excludes_queue( Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_worker_is_draining_follows_the_job_worker(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let job = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", + ) + .bind(job) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag, running, started_at, worker) + VALUES ($1, 'test-workspace', now(), 'deno', true, now(), 'wk-drain-test')", + ) + .bind(job) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO worker_ping (worker, worker_instance, ip, worker_group, wm_version) + VALUES ('wk-drain-test', 'host', 'NO IP', 'default', 'v0')", + ) + .execute(&db) + .await?; + + let url = format!("http://localhost:{port}/api/w/test-workspace/jobs/worker_is_draining/{job}"); + let draining = || async { + let resp = authed(client().get(&url)).send().await?; + anyhow::Ok(resp.json::().await?) + }; + assert!(!draining().await?); + + windmill_common::worker::set_worker_draining_query("wk-drain-test", &db).await?; + assert!(draining().await?); + + // A parked job keeps its last worker on the queue row without running on it. + sqlx::query("UPDATE v2_job_queue SET started_at = NULL WHERE id = $1") + .bind(job) + .execute(&db) + .await?; + assert!(!draining().await?); + + Ok(()) +} diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index 0102f1b033..cf0b85609a 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -86,6 +86,7 @@ struct WorkerPing { job_isolation: Option, #[serde(skip_serializing_if = "Option::is_none")] native_mode: Option, + draining: bool, } // #[derive(Serialize, Deserialize)] @@ -118,7 +119,7 @@ async fn list_worker_pings( WorkerPing, "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed, CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id, - custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode + custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining FROM worker_ping WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval) ORDER BY ping_at desc LIMIT $2 OFFSET $3", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 9173eae28c..5e635e39bd 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -17322,6 +17322,27 @@ paths: schema: type: string + /w/{workspace}/jobs/worker_is_draining/{id}: + get: + summary: whether the worker running this job is draining + description: | + True once the worker running the job has received its shutdown signal. The worker + still waits for the job to finish, so a long-running job can poll this to exit early. + False when the job is not currently running on a worker. + operationId: workerIsDraining + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: whether the worker running this job is draining + content: + application/json: + schema: + type: boolean + /w/{workspace}/jobs_u/get_logs/{id}: get: summary: get job logs @@ -35901,6 +35922,9 @@ components: type: string native_mode: type: boolean + draining: + type: boolean + description: the worker received its shutdown signal and exits once its current job is done required: - worker - worker_instance diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 7d700c4ab6..5b0da21c50 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -378,6 +378,10 @@ pub fn workspaced_service() -> Router { "/wac_approval_urls/{job_id}/{step_key}", get(get_wac_approval_urls).layer(cors.clone()), ) + .route( + "/worker_is_draining/{id}", + get(worker_is_draining).layer(cors.clone()), + ) .route( "/result_by_id/{job_id}/{node_id}", get(get_result_by_id).layer(cors.clone()), @@ -613,6 +617,53 @@ async fn get_root_job( Ok(Json(res)) } +/// Whether the worker running this job has received its shutdown signal. A draining worker +/// never interrupts its job, so a long-running script polls this to exit on its own terms. +async fn worker_is_draining( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> windmill_common::error::JsonResult { + // A job always may ask about itself. Anyone else must be able to see the job: the + // caller's row policies on v2_job decide, narrowed by the token's tag filter. + let draining = if authed.job_id == Some(id) { + sqlx::query_scalar!( + "SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker + WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL", + id, + w_id + ) + .fetch_optional(&db) + .await? + } else { + let tags = get_scope_tags(&authed) + .map(|tags| tags.into_iter().map(str::to_string).collect::>()); + let mut tx = user_db.begin(&authed).await?; + let draining = sqlx::query_scalar!( + "SELECT wp.draining FROM v2_job j + JOIN v2_job_queue q ON q.id = j.id + JOIN worker_ping wp ON wp.worker = q.worker + WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL + AND ($3::text[] IS NULL OR j.tag = ANY($3))", + id, + w_id, + tags.as_deref() + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + draining + } + .unwrap_or(false); + windmill_common::feature_usage::log_feature_usage( + "worker_draining", + "check", + if draining { "draining" } else { "not_draining" }, + ); + Ok(Json(draining)) +} + async fn compute_root_job_for_flow(db: &DB, w_id: &str, job_id: Uuid) -> error::Result { let root_job = sqlx::query_scalar!( r#"SELECT COALESCE(root_job, flow_innermost_root_job, parent_job, id) as "root_job!" FROM v2_job WHERE id = $1 AND workspace_id = $2"#, diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index e185d1d445..52fa2da905 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -1952,6 +1952,7 @@ pub enum PingType { MainLoop, Job, InitScript, + Draining, } #[derive(Serialize, Deserialize)] pub struct Ping { @@ -1974,6 +1975,8 @@ pub struct Ping { pub occupancy_rate_30m: Option, pub job_isolation: Option, pub native_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draining: Option, pub ping_type: PingType, } pub async fn update_ping_http( @@ -2026,6 +2029,7 @@ pub async fn update_ping_http( insert_ping.memory, insert_ping.job_isolation, insert_ping.native_mode.unwrap_or(false), + insert_ping.draining, db, ) .await?; @@ -2054,10 +2058,21 @@ pub async fn update_ping_http( ) .await? } + PingType::Draining => set_worker_draining_query(worker_name, db).await?, } Ok(()) } +pub async fn set_worker_draining_query(worker_name: &str, db: &DB) -> anyhow::Result<()> { + sqlx::query!( + "UPDATE worker_ping SET draining = true WHERE worker = $1", + worker_name + ) + .execute(db) + .await?; + Ok(()) +} + #[derive(Debug, Serialize, Deserialize)] pub struct JobCancelled { pub canceled_by: String, @@ -2156,6 +2171,9 @@ pub async fn fetch_raw_script_from_app_query( /// `started_at` and `jobs_executed` are the only two columns carried over, being the /// continuity itself — plus `ip` for as long as `ip` is `None`, which means the external IP /// lookup has not resolved yet and the predecessor's address is still the best guess. +/// +/// `draining` is `Some(false)` from a worker starting up and `None` from one re-registering +/// mid-life, which must leave alone a flag its own shutdown may have set in the meantime. pub async fn insert_ping_query( worker_instance: &str, worker_name: &str, @@ -2169,6 +2187,7 @@ pub async fn insert_ping_query( memory: Option, job_isolation: Option, native_mode: bool, + draining: Option, db: &DB, ) -> anyhow::Result { // A NULL `ip` means the external IP lookup is still in flight; a later ping fills it in, and @@ -2176,8 +2195,8 @@ pub async fn insert_ping_query( // lookup that has failed reports `external_ip::UNRETRIEVABLE_IP`, which does overwrite it. The // literal below must stay equal to `external_ip::UNKNOWN_IP`. let previous_jobs_executed = sqlx::query_scalar!( - "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker) - DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL + "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker) + DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining) RETURNING jobs_executed", worker_instance, worker_name, @@ -2191,6 +2210,7 @@ pub async fn insert_ping_query( memory, job_isolation.as_deref(), native_mode, + draining, ) .fetch_one(db) .await?; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 01523eba59..745f202826 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -643,6 +643,7 @@ pub async fn update_worker_ping_for_failed_init_script( wm_memory_usage: None, job_isolation: None, native_mode: None, + draining: None, ping_type: PingType::InitScript, }, ) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 2acafa539f..d0d5d8b4e2 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -155,7 +155,7 @@ use crate::{ handle_app_dependency_job, handle_dependency_job, handle_flow_dependency_job, tally_unfinished_dependency_deploy, }, - worker_utils::{insert_ping, queue_vacuum, update_worker_ping_full}, + worker_utils::{insert_ping, mark_worker_draining, queue_vacuum, update_worker_ping_full}, }; #[cfg(feature = "rust")] @@ -2713,7 +2713,7 @@ pub async fn run_worker( let mut last_ping = Instant::now() - Duration::from_secs(NUM_SECS_PING + 1); let mut reported_ip = cached_ip(); - let previous_jobs_executed = insert_ping(hostname, &worker_name, reported_ip, conn) + let previous_jobs_executed = insert_ping(hostname, &worker_name, reported_ip, true, conn) .await .expect("initial ping could be sent"); @@ -3160,6 +3160,31 @@ pub async fn run_worker( let mut killpill_rx2 = killpill_rx.resubscribe(); + // The loop below only sees the killpill once the running job is done, which is too late + // for that job to learn its worker is going away. + { + let mut killpill_rx = killpill_rx.resubscribe(); + let conn = conn.clone(); + let worker_name = worker_name.clone(); + tokio::spawn(async move { + if matches!( + killpill_rx.recv().await, + Err(broadcast::error::RecvError::Closed) + ) { + return; + } + for attempt in 1..=3 { + match mark_worker_draining(&conn, &worker_name).await { + Ok(()) => return, + Err(e) => { + tracing::warn!(worker = %worker_name, "failed to mark worker as draining (attempt {attempt}/3): {e:#}"); + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } + }); + } + loop { let last_processing_duration_secs = last_processing_duration.load(Ordering::SeqCst); if last_processing_duration_secs > 5 { @@ -3235,7 +3260,7 @@ pub async fn run_worker( // which costs at most the last job's id here: no job of this worker is in flight at this // point in the loop, and the next one refills them. if ip_just_resolved && conn.as_sql().is_none() { - if let Err(e) = insert_ping(hostname, &worker_name, ip, &conn).await { + if let Err(e) = insert_ping(hostname, &worker_name, ip, false, &conn).await { tracing::warn!( worker = %worker_name, hostname = %hostname, "failed to re-register with the resolved external IP: {e}" diff --git a/backend/windmill-worker/src/worker_utils.rs b/backend/windmill-worker/src/worker_utils.rs index 3a313e9360..cabf004f4d 100644 --- a/backend/windmill-worker/src/worker_utils.rs +++ b/backend/windmill-worker/src/worker_utils.rs @@ -7,9 +7,9 @@ use windmill_common::{ external_ip::UNKNOWN_IP, worker::{ get_memory, get_vcpus, get_windmill_memory_usage, get_worker_memory_usage, - insert_ping_query, update_job_ping_query, update_worker_ping_from_job_query, - update_worker_ping_main_loop_query, Connection, Ping, PingType, NATIVE_MODE_RESOLVED, - WORKER_CONFIG, WORKER_GROUP, + insert_ping_query, set_worker_draining_query, update_job_ping_query, + update_worker_ping_from_job_query, update_worker_ping_main_loop_query, Connection, Ping, + PingType, NATIVE_MODE_RESOLVED, WORKER_CONFIG, WORKER_GROUP, }, KillpillSender, DB, }; @@ -164,6 +164,7 @@ async fn update_worker_ping_full_inner( wm_memory_usage: get_windmill_memory_usage(), job_isolation: None, native_mode: Some(native_mode), + draining: None, ping_type: PingType::MainLoop, }, ) @@ -179,6 +180,7 @@ pub async fn insert_ping( worker_instance: &str, worker_name: &str, ip: Option<&str>, + reset_draining: bool, db: &Connection, ) -> anyhow::Result { let (tags, dw, dws, native_mode) = { @@ -224,6 +226,7 @@ pub async fn insert_ping( memory, job_isolation, native_mode, + reset_draining.then_some(false), db, ) .await; @@ -256,6 +259,7 @@ pub async fn insert_ping( wm_memory_usage: get_windmill_memory_usage(), job_isolation, native_mode: Some(native_mode), + draining: reset_draining.then_some(false), ping_type: PingType::Initial, }, ) @@ -314,6 +318,7 @@ pub async fn update_worker_ping_from_job( &Ping { last_job_executed: Some(job_id.clone()), last_job_workspace_id: Some(w_id.to_string()), + draining: None, ping_type: PingType::Job, worker_instance: None, ip: None, @@ -342,6 +347,45 @@ pub async fn update_worker_ping_from_job( Ok(()) } +/// Publishes on the worker's ping row that it has received its killpill, so the job it is +/// still running can find out and exit early. +pub async fn mark_worker_draining(conn: &Connection, worker_name: &str) -> anyhow::Result<()> { + match conn { + Connection::Sql(db) => set_worker_draining_query(worker_name, db).await, + Connection::Http(client) => { + client + .post::( + UPDATE_PING_URL, + None, + &Ping { + last_job_executed: None, + last_job_workspace_id: None, + draining: None, + ping_type: PingType::Draining, + worker_instance: None, + ip: None, + tags: None, + dw: None, + dws: None, + version: None, + vcpus: None, + memory: None, + memory_usage: None, + wm_memory_usage: None, + jobs_executed: None, + occupancy_rate: None, + occupancy_rate_15s: None, + occupancy_rate_5m: None, + occupancy_rate_30m: None, + job_isolation: None, + native_mode: None, + }, + ) + .await + } + } +} + pub async fn ping_job_status( conn: &Connection, job_id: &Uuid, diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index dc9541b4c5..e5b3619a27 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -839,6 +839,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -1674,6 +1682,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -2615,6 +2631,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -4620,6 +4644,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/frontend/src/routes/(root)/(logged)/workers/+page.svelte b/frontend/src/routes/(root)/(logged)/workers/+page.svelte index f7e0f07a54..0fb9243276 100644 --- a/frontend/src/routes/(root)/(logged)/workers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workers/+page.svelte @@ -1022,7 +1022,7 @@ {#if workers} - {#each workers as { worker, custom_tags, last_ping, started_at, jobs_executed, last_job_id, last_job_workspace_id, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, occupancy_rate, wm_version, vcpus, memory, memory_usage, wm_memory_usage, native_mode }} + {#each workers as { worker, custom_tags, last_ping, started_at, jobs_executed, last_job_id, last_job_workspace_id, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, occupancy_rate, wm_version, vcpus, memory, memory_usage, wm_memory_usage, native_mode, draining }} {@const isWorkerAlive = isWorkerMaybeAlive(last_ping)} {@const tagMismatchInfo = getTagMismatchInfo( custom_tags, @@ -1087,6 +1087,15 @@ {#if native_mode} Native {/if} + {#if draining} + + Draining + {#snippet text()} + This worker received a shutdown signal. It no longer pulls jobs + and exits once its current job is done. + {/snippet} + + {/if}
{#if !config || !config.worker_tags || config.worker_tags.length === 0} diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index ffcb6a3fe1..97da60aadc 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -823,6 +823,22 @@ class Windmill: else: return r.json() + def worker_is_draining(self, job_id: Optional[str] = None) -> bool: + """Whether the worker running the job has received its shutdown signal. + + A draining worker waits for its current job to finish before exiting. A long-running + script can poll this and return early so the worker can be replaced. + + Args: + job_id: Job ID (defaults to current WM_JOB_ID) + + Returns: + True if the worker running the job is draining + """ + job_id = job_id or os.environ.get("WM_JOB_ID") + + return self.get(f"/w/{self.workspace}/jobs/worker_is_draining/{job_id}").json() + def set_flow_user_state(self, key: str, value: Any) -> None: """Set the user state of a flow at a given key""" flow_id = self.get_root_job_id() @@ -2027,6 +2043,17 @@ def get_progress(job_id: Optional[str] = None) -> Any: return _client.get_progress(job_id) +@init_global_client +def worker_is_draining(job_id: Optional[str] = None) -> bool: + """ + Whether the worker running this job has received its shutdown signal. + + A draining worker waits for its current job to finish before exiting. A long-running + script can poll this and return early so the worker can be replaced. + """ + return _client.worker_is_draining(job_id) + + def set_shared_state_pickle(value: Any, path="state.pickle") -> None: """ Set the state in the shared folder using pickle diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 00e5655154..2a5e48d5fe 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1724,6 +1724,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -2396,6 +2404,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index cb5ae40490..14ca869180 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1639,6 +1639,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -2311,6 +2319,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 4241d49efa..13a93ed063 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -240,6 +240,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index 47b0be888d..55df35ccf7 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -196,6 +196,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 21b2f382c3..6ad44db068 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -392,6 +392,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index f9e902fe78..a5f599a946 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -404,6 +404,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index 71608cd16e..ce0c610da7 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -394,6 +394,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index decb356ba6..4946ed0f6e 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -448,6 +448,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index 92e11640f3..41a8eac37e 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -22,6 +22,6 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, workerIsDraining, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" diff --git a/typescript-client/build.sh b/typescript-client/build.sh index 127afe2154..f927acd26e 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -46,7 +46,7 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, type TaskRetry, type Jsonified, type JsonifiedFn, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, workerIsDraining, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, type TaskRetry, type Jsonified, type JsonifiedFn, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" # Build default export by combining client utilities + services # This preserves backward compatibility for `import wmill from "windmill-client"` @@ -63,6 +63,7 @@ import { setState, setProgress, getProgress, + workerIsDraining, getState, getIdToken, denoS3LightClientSettings, @@ -153,6 +154,7 @@ const wmill = { setState, setProgress, getProgress, + workerIsDraining, getState, getIdToken, denoS3LightClientSettings, diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 4ec07d6fa6..3f7b03117a 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -724,6 +724,19 @@ export async function getProgress(jobId?: any): Promise { }); } +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +export async function workerIsDraining(jobId?: string): Promise { + return await JobService.workerIsDraining({ + id: jobId ?? getEnv("WM_JOB_ID") ?? "NO_JOB_ID", + workspace: getWorkspace(), + }); +} + /** * Set a flow user state * @param key key of the state From 2758920f1182ddd12e6473a644a4a966a435522e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 13:02:26 +0200 Subject: [PATCH 110/174] fix: sync back volume files rewritten to the same size (#11620) * fix: upload volume files rewritten to the same size Co-Authored-By: Claude Opus 5.5 * fix: never match a volume file stamp with an unreadable change time Co-Authored-By: Claude Opus 5.5 * chore: bump ee ref Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 127575148c84bb6132d2946c0a95c6dea0f58bb2 This commit updates the EE repository reference after PR #860 was merged in windmill-ee-private. Previous ee-repo-ref: e06755ddd48c834e75537cf44da017fdc3a9585b New ee-repo-ref: 127575148c84bb6132d2946c0a95c6dea0f58bb2 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 +- backend/windmill-worker-volumes/src/lib.rs | 34 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ff44a56995..14bd926cf5 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -55feb2e84332179faa4e17aeb419f7f480df79a6 +127575148c84bb6132d2946c0a95c6dea0f58bb2 diff --git a/backend/windmill-worker-volumes/src/lib.rs b/backend/windmill-worker-volumes/src/lib.rs index e2e4ab866b..364795510a 100644 --- a/backend/windmill-worker-volumes/src/lib.rs +++ b/backend/windmill-worker-volumes/src/lib.rs @@ -54,11 +54,45 @@ pub struct VolumeMount { pub target: String, } +/// A file's size and last change time. Sync-back trusts a pulled file's stored hash only while +/// its stamp is the one taken at hand-off: size alone misses a rewrite of the same length. A +/// write within the filesystem's timestamp granularity of the hand-off is not seen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FileStamp { + size: u64, + changed: Option<(i64, i64)>, +} + +impl FileStamp { + /// Whether the file is as it was at `earlier`. An unreadable change time never matches. + pub fn unchanged_since(&self, earlier: &FileStamp) -> bool { + self.changed.is_some() && self == earlier + } + + pub fn of(meta: &std::fs::Metadata) -> Self { + // ctime rather than mtime: a tool that restores mtime (cp -p, tar, rsync -t) cannot set it. + #[cfg(unix)] + let changed = { + use std::os::unix::fs::MetadataExt; + Some((meta.ctime(), meta.ctime_nsec())) + }; + #[cfg(not(unix))] + let changed = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| (d.as_secs() as i64, d.subsec_nanos() as i64)); + Self { size: meta.len(), changed } + } +} + pub struct VolumeState { pub mount: VolumeMount, pub local_dir: PathBuf, pub manifest: HashMap, pub symlinks: HashMap, + /// Stamp of each pulled file as the job received it, keyed like `manifest`. + pub stamps: HashMap, } pub struct DownloadStats { From a85d0b76cc494aaa4a484a273ec534bdc2958102 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 13:03:47 +0200 Subject: [PATCH 111/174] feat: add Pi template to the AI sandbox picker (#11619) Co-authored-by: Claude Opus 5.5 --- frontend/src/lib/components/EditorBar.svelte | 1 + .../src/lib/components/ScriptBuilder.svelte | 22 +++++- .../src/lib/components/ScriptEditor.svelte | 1 + .../common/languageIcons/LanguageIcon.svelte | 33 ++++++++- .../flows/content/FlowInputs.svelte | 12 +++ .../flows/content/FlowInputsQuick.svelte | 73 +++++++++--------- .../components/flows/flowStateUtils.svelte.ts | 2 +- .../flows/map/InsertModuleInner.svelte | 28 ++++--- .../flows/pickers/FlowScriptPicker.svelte | 2 +- .../pickers/FlowScriptPickerQuick.svelte | 2 +- .../flows/pickers/TopLevelNode.svelte | 1 + .../components/graph/graphBuilder.svelte.ts | 2 +- frontend/src/lib/components/script_builder.ts | 1 + frontend/src/lib/script_helpers.ts | 8 ++ .../src/lib/templates/pi_sandbox.ts.template | 74 +++++++++++++++++++ 15 files changed, 208 insertions(+), 54 deletions(-) create mode 100644 frontend/src/lib/templates/pi_sandbox.ts.template diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index 81ef6c1c96..2db471e2c8 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -105,6 +105,7 @@ | 'powershell' | 'bunnative' | 'claudesandbox' + | 'pisandbox' | 'wac_python' | 'wac_typescript' | 'ci_test_bun' diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index ad869ed492..f69cf37f1d 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -596,6 +596,7 @@ | 'powershell' | 'bunnative' | 'claudesandbox' + | 'pisandbox' | 'wac_python' | 'wac_typescript' | 'ci_test_bun' @@ -1543,10 +1544,12 @@ (lang == script.language && template != 'bunnative' && template != 'docker' && - template != 'claudesandbox') || + template != 'claudesandbox' && + template != 'pisandbox') || (template == 'bunnative' && lang == 'bunnative') || (template == 'docker' && lang == 'docker') || - (template == 'claudesandbox' && lang == 'bun')} + ((template == 'claudesandbox' || template == 'pisandbox') && + lang == 'bun')} @@ -1597,6 +1600,21 @@ > Claude Sandbox +
{/if} + {#if lang === 'pisandbox'} +
+ +
+ {/if}
diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index f86d870a1a..41d860530a 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -291,6 +291,18 @@ }) }} /> + { + dispatch('new', { + language: 'bun', + kind, + subkind: 'pisandbox', + summary + }) + }} + />
{/if} diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index 0b78193c6a..ecb3187c5c 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -267,28 +267,33 @@ ) let aiLength = $derived(showAiRows ? 2 : 0) - // Must match the heading and label rendered for the AI Sandbox section const aiSandboxHeading = 'AI Sandbox' - const aiSandboxLabel = 'Claude Code' - let matchesAiSandbox = $derived.by(() => { + const aiSandboxes = [ + { label: 'Claude Code', subkind: 'claudesandbox' }, + { label: 'Pi', subkind: 'pisandbox' } + ] as const + // A query matching the heading keeps every sandbox, otherwise only the ones it names. + let matchingAiSandboxes = $derived.by(() => { const query = funcDesc?.trim().toLowerCase() ?? '' - if (query.length == 0) { - return true - } - return [aiSandboxHeading, aiSandboxLabel].some((term) => { + const matches = (term: string) => { const lowered = term.toLowerCase() return lowered.startsWith(query) || lowered.split(' ').some((w) => w.startsWith(query)) - }) + } + if (query.length == 0 || matches(aiSandboxHeading)) { + return aiSandboxes + } + return aiSandboxes.filter((s) => matches(s.label)) }) - // Gates the AI Sandbox row and its slot in the index space; both must agree or arrow keys land - // on an index that renders nothing. - let showAiSandbox = $derived( + // Gates the AI Sandbox rows and their slots in the index space; both must agree or arrow keys + // land on an index that renders nothing. + let aiSandboxRows = $derived( selectedKind === 'script' && preFilter === 'all' && !selected && customUi?.aiSandbox != false && - !$operatorBuilderFlows && - matchesAiSandbox + !$operatorBuilderFlows + ? matchingAiSandboxes + : [] ) // Hub runnables carry code the workspace never reviewed, and the backend refuses them in a @@ -301,7 +306,7 @@ let aiOffset = $derived(inlineOffset + (inlineScripts?.length ?? 0)) let workspaceOffset = $derived(aiOffset + aiLength) let aiSandboxOffset = $derived(workspaceOffset + (filteredWorkspaceItems?.length ?? 0)) - let hubOffset = $derived(aiSandboxOffset + (showAiSandbox ? 1 : 0)) + let hubOffset = $derived(aiSandboxOffset + aiSandboxRows.length) @@ -530,27 +535,29 @@ {/await}
{/if} - {#if showAiSandbox} + {#if aiSandboxRows.length > 0}
{aiSandboxHeading}
- hover(aiSandboxOffset)} - enterpriseLangs={[]} - label={aiSandboxLabel} - lang="claudesandbox" - on:click={() => { - dispatch('new', { - kind: selectedKind, - inlineScript: { - language: 'bun', + {#each aiSandboxRows as sandbox, i (sandbox.subkind)} + hover(aiSandboxOffset + i)} + enterpriseLangs={[]} + label={sandbox.label} + lang={sandbox.subkind} + on:click={() => { + dispatch('new', { kind: selectedKind, - subkind: 'claudesandbox', - summary - } - }) - }} - /> + inlineScript: { + language: 'bun', + kind: selectedKind, + subkind: sandbox.subkind, + summary + } + }) + }} + /> + {/each} {/if} {#if showHub && selectedKind != 'preprocessor' && selectedKind != 'flow'} {#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')} diff --git a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts index 9d85a07452..030897a63b 100644 --- a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts +++ b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts @@ -108,7 +108,7 @@ export async function pickFlow( export async function createInlineScriptModule( language: RawScript['language'], kind: Script['kind'], - subkind: 'pgsql' | 'flow' | 'claudesandbox' | undefined, + subkind: 'pgsql' | 'flow' | 'claudesandbox' | 'pisandbox' | undefined, id: string, summary?: string ): Promise<[FlowModule, FlowModuleState]> { diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 9cf4bcd264..62ec111b6c 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -117,17 +117,21 @@ logReusableAgentUsage('linked') dispatch('new', { kind: 'aiagent', agentPath }) } - function newClaudeSandbox() { + function newAiSandbox(subkind: 'claudesandbox' | 'pisandbox') { dispatch('close') dispatch('new', { kind: 'script', inlineScript: { language: 'bun', kind: 'script', - subkind: 'claudesandbox' + subkind } }) } + const aiSandboxes = [ + { label: 'Claude Code', subkind: 'claudesandbox' }, + { label: 'Pi', subkind: 'pisandbox' } + ] as const // Rows of the AI panes in render order. FlowInputsQuick owns the keyboard index for the other // panes and is unmounted while these render, so the two never fight over Enter or the arrows. @@ -137,7 +141,7 @@ return [newBlankAgent, ...saved.map((a) => () => newLinkedAgent(a.path))] } if (selectedKind === 'aisandbox') { - return [newClaudeSandbox] + return aiSandboxes.map((s) => () => newAiSandbox(s.subkind)) } return [] }) @@ -418,14 +422,16 @@
{:else if selectedKind === 'aisandbox'}
- (selectedByKeyboard = 0)} - /> + {#each aiSandboxes as sandbox, i (sandbox.subkind)} + newAiSandbox(sandbox.subkind)} + onHover={() => (selectedByKeyboard = i)} + /> + {/each}
{:else} ", + // set the API key env var it reads, and name it in `model` below. + process.env.ANTHROPIC_API_KEY = anthropic.apiKey; + const models = createModels(); + models.setProvider(anthropicProvider()); + const model = { provider: "anthropic", modelId: "claude-opus-5-5" }; + + // read, write, edit and bash, running in this job's sandbox + const registry = createRegistry(); + registry.install(CodingTools); + + const context = BACKGROUND_CONTEXT; + + // The conversation is stored in the volume, so each run continues the previous one. + const harness = await Harness.open( + await openNodeJsonlStorage(".pi/session", context), + { models, registry, env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? process.cwd() }) }, + context, + ); + + try { + const root = await harness.root(context); + await root.configure({ model, instructions: instructions ?? null }, context); + + const isResume = (await root.viewState(context)).value.entries.length > 0; + + // you can hardcode the prompt or pass it as input. + const prompt = !isResume ? + "What is the fastest OSS workflow engine?" : + "What did I ask you before?" + + const events = await watchEvents(harness, root.id, context); + events.start(async (batch) => { + for (const event of batch) { + if (event.type === "tool_execution_start") { + console.log(`${event.toolName} ${JSON.stringify(event.args)}`); + } + } + }); + + const submission = await root.submit({ type: "input", content: prompt }, context); + const settled = await submission.wait(context); + if (settled.status !== "done" || settled.type !== "input") { + throw new Error(`Pi did not answer: ${JSON.stringify(settled)}`); + } + + const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context); + const response = (answer?.model?.[0]?.content ?? []) + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join(""); + + return { + is_resume: isResume, + prompt, + response, + usage: (await harness.usage(context)).models, + }; + } finally { + await harness.close(context); + } +} From 3dae553576ba23b2c1f56e4deac01305c4645c01 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:23:29 +0200 Subject: [PATCH 112/174] feat: pipeline editor: assets-only view, Fix actions and draft triggers (#11440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip: one sidebar for operators and developers, with a detach mode Co-Authored-By: Claude Opus 5 (1M context) * feat: snap-detach the operator sidebar, and join the handle to the picker Dragging the sidebar into the left snap zone detaches it for an operator, who has no icon-only rail to collapse into; the width the drag started from is what gets persisted, so re-attaching restores the sidebar's size. The handle's colour pastille slides onto the card's workspace pastille as the card opens, alongside the workspace name, and both buttons that attach or detach the sidebar carry a tooltip. Co-Authored-By: Claude Opus 5 (1M context) * feat: attach and detach from the sidebar header, open the card under its handle The button that swaps the sidebar between docked and detached moves to the header, next to the workspace name, so it stays in one place across the swap; the icon-only rail has no room for it and keeps the footer copy. The handle itself becomes a component of its own, carries the workspace icon its picker uses, and opens the card rather than docking — docking is the header button's job. The card is placed under the handle measured at open time instead of at a fixed offset. Co-Authored-By: Claude Opus 5 (1M context) * feat: one page header across routes, with the workspace picker in it Every route now wears the same bar above its content: the sidebar toggle, the workspace picker, and whatever the route registers — an item's breadcrumb and summary, or a section name — with the route's own buttons at the far end. The bar's row is level with the sidebar's own top row, which the workspace ⇄ sessions switch now owns, so the two read as one band. A route fills the bar by mounting PageHeaderContent, which registers a getter rather than a snapshot: the layout calls it while rendering, so a path or summary the page edits reaches the bar without an effect keeping two copies in step. Nothing registers yet; the routes follow. Co-Authored-By: Claude Opus 5 (1M context) * feat: detail pages fill the page header, which now spans above the sidebar A script or flow detail page no longer draws a bar of its own: its path becomes the header's breadcrumb, its summary keeps the rename-and-labels popover next to it, and its badges, menu and buttons ride along as the header's actions. The band runs the full width with the sidebar hanging below it. A snippet rendered in the header initialises its components under the header, not under the page that wrote it, so anything the page's tree provides is lost — the detail page's buttons read TriggerContext and threw. ContextBridge carries the entries a registration names back around the render. The floating handle is gone with it: the header's own toggle is the one control, hovering it peeks the sidebar in and clicking pins it. The workspace picker in the header is a denser variant of the sidebar's, and the card's outside-click catcher starts at the card rather than the viewport top, so it no longer swallows clicks meant for the toggle. Co-Authored-By: Claude Opus 5 (1M context) * feat: one breadcrumb in the page header, and the banners that used to sit under it The header band now carries a single breadcrumb — workspace, fork, then the item's path — built from one component, so every segment has the same size, weight and icon size, and slashes separate them. The item's kind icon rides with its summary, after a dot, rather than with the path. The fork and drafts banners move into the band as badges plus a subtle Review button: ahead/behind counts instead of a bare change total, and the per-kind breakdown stays on the compare page. The home hero's CLI, MCP and Hub links move into an ellipsis menu, alongside the toggle that pins the session chat to the page. The flow editor and the app viewer hand their bar to the band too, and a route can drop the band's bottom border when its own content draws one — the border stays in the box with a transparent colour, so contents never shift a pixel across a navigation. Co-Authored-By: Claude Opus 5 (1M context) * feat: the runs page's own header row moves into the page header The title, the queue counters, the kind and status filters, the schedule and future-job switches, the search field and the timeframe picker all ride the band now, and the page starts at the chart — one row of vertical space back. The two toggle groups appear from 1500px: below that the bar cannot hold them next to the search field, and the same filtering is reachable from the search field's own kind and status chips. The search field is what gives width back, and the breadcrumb yields last, so a page's own name stays whole while the workspace and fork names truncate. The queue counters keep their icon form at every width, since the bar has room for two counters and never for the two labelled blocks. Their counts hang outside their icons, so the group carries the margins that keep a badge off its neighbour, and the suspended counter answers a hover at zero rather than sitting there as a dead icon. The band is 44px rather than 40, and a page can hand the breadcrumb a hint — the documentation tooltip that belongs with the page's name rather than with its buttons. Co-Authored-By: Claude Opus 5 (1M context) * feat: no border under the page header, and the peek card closes on the way out The band draws no bottom edge and no page asks it to, so `barBorder` goes with it — a page that needs the separation draws its own line, as the detail pages now do above the run form and the tab strip. With nothing toggling a border, the band is a flat 44px on every route. The detached sidebar's card is a hover affordance, so it leaves when the pointer does: off the handle and off the card both, on the same debounce a hover menu uses, because the two do not touch and the leave of one fires before the enter of the other. The mobile drawer is opened deliberately and still stays until it is dismissed. Co-Authored-By: Claude Opus 5 (1M context) * feat: one band, one workspace part, and the pickers that hang off it The band is a single element whose placement is styled rather than branched: a page registering a side-panel inset used to swap it between two `{#if}` arms, destroying the header and rebuilding it a frame later — a blink of no header with the content sliding up into the gap. The workspace and its fork become one breadcrumb part: the name leads home (inert in a session, where the workspace is the session's scope rather than a place to go) and one chevron opens the picker, which already expands a family to reach its forks. The picker's panel hangs from the part's left edge, opens with the active family expanded, and closes before the switch runs — the switch renames the workspace, which moves the trigger the panel hangs from, and floating-ui was chasing it across the viewport. The item's path is a plain label rather than a row of pickers: same separators and weight, and a click copies it with its own confirmation. The kind icon goes, the page below says what kind it is. Around them: a "prod" badge for a root workspace, the session's acting fork beside the session's name rather than in the breadcrumb, the edit-in-dev affordance as a button in the header, the drafts count as a sentence, and a fade when the workspace changes under a page that stays mounted. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a page's own controls when the workspace navigation is hidden `menuHidden` means "this is an embed, don't show the workspace's navigation" — but Runs, the detail pages and the flow editor now keep their controls in the band and nowhere else, so hiding it took the filters, the timeframe and the page's buttons with it. A session's preview iframe opens exactly that way. The band now renders for an embed too, without its breadcrumb, so the page keeps its controls and the embed still gains no way to navigate the host's workspace. The flow editor's autosave indicator moves into the header's actions with the rest of its bar: saving state, the autosave toggle and other people's drafts were left behind in the branch the full-page editor no longer takes. Co-Authored-By: Claude Opus 5 (1M context) * fix: the header carries what it promised, and nothing it does not The registry's item described pickers the breadcrumb no longer has: nine fields — the editability flags, the change callbacks, the navigation hook — that nothing read, while the flow editor passed eight of them and quietly lost its inline rename. The type now says what the bar does: a path, and either a summary or the page's own control for one. The flow editor renames through that control, as the detail page already did. `barPlacement` went the same way: no page registered 'inline', so the layout's guard never fired. The sessions page registered an inset of 0 whenever its preview pane never mounted — the "no session selected" and opted-out arms — and 0 reads as "a page owns the right edge", floating the band over the page's first line. It registers an inset only once the pane has a width. The band's sidebar handle docks through `setDetached` like the card's own toggle, so docking still suppresses the rail's entry animation and closes the card; a summary without its breadcrumb no longer leads with a stray separator in an embed; a page's hint sits with its name whichever way the page named itself; and `Menu.closeInstantly` and `EditorHeader`'s `inline`/`hideSummary` go, their callers having been replaced. Co-Authored-By: Claude Opus 5 (1M context) * feat: a deployed app gets the viewport, and the header waits in the corner An app is the page, not a page inside Windmill's chrome, so the band leaves the flow and floats above the viewport until the handle in the app's corner calls it down. The handle is faded until the pointer nears it, hovering peeks the band over the app, and a click pins it — one flag for every deployed app, in this browser, because someone who wants the breadcrumb over a dashboard wants it over the next one too. Peeking overlays rather than pushes: the app is an iframe, and taking 44px off its box on every pass of the pointer would re-lay-out whatever it draws. Pinned, the band is in flow like any other route's and the app starts under it. The sidebar is untouched — docked stays docked, detached stays a peek — since the pin is about the header alone. A page says it wants the viewport with one registry field, so the layout keeps asking the registry rather than knowing about apps. Co-Authored-By: Claude Opus 5 (1M context) * feat: one corner control for the chrome an app page hides Hovering the button overlaid on a deployed app brings back whatever that page is hiding: the band always, and the sidebar card too when the sidebar is detached. The card hangs from this button rather than from the header's own handle, which is off-screen with the band. The handle in the band no longer attaches the sidebar — it reveals it and nothing more. A control that hides the thing it sits on cannot also be the way to bring it back, so attaching stays with the toggle in the sidebar's own footer, where detaching happens. The band's two buttons go in the order the things nest: the sidebar's handle, then the header's pin. The overlaid button stands where the pin will be once pinned — same 28px box, and a slot further along when the sidebar's handle takes the first — so pinning moves nothing. It wears the panel pair the sidebar's toggle uses: solid while the band is away, dashed while it is shown. Co-Authored-By: Claude Opus 5 (1M context) * feat: the access point sits in the corner, and the header covers it The button is in the page's top-left corner — 8px in, or just past the rail when the sidebar is docked — rather than shifted along to where the header's pin would land. Hovering brings the header down over it: an access point that the regular view covers once it arrives, so there is nothing to line up and nothing to move. Because the header lands on the button, the pointer leaves it the instant it arrives. The band holds both itself and the sidebar card open while the pointer is anywhere on it, or the card would retract under a header that stayed. Co-Authored-By: Claude Opus 5 (1M context) * feat: on an app page the floating button owns both, and they move together The band drops its sidebar handle there: the floating control in the corner reveals the sidebar along with the band, and two handles for one gesture is one too many. The two are then linked — the pointer resting on either holds both, and leaving either collapses both — so they behave as the single thing the one button revealed. The card meets the header in a line: it hangs from the header's bottom edge rather than from whichever control opened it, which had it starting a couple of pixels high. With no header to sit under — an embed — it still hangs from the control that reached for it. Co-Authored-By: Claude Opus 5 (1M context) * feat: the revealed sidebar is a panel under the header, not a card over the page No rounded corners, no inset from the window: it starts at the header's bottom edge and runs to the left and bottom edges, with one border down its right side and a shadow to lift it off the page. A card floating in from nowhere read as a separate thing; this reads as the sidebar the header sits on top of. Co-Authored-By: Claude Opus 5 (1M context) * fix: only an app page gets the flush panel; elsewhere the card still floats Over an app the sidebar reads as the panel the header sits on — flush left and bottom, square, one border down its right side. On every other page it is what it was: a card inset from the window with rounded corners and a border all round. Co-Authored-By: Claude Opus 5 (1M context) * fix: an app opens with the header already away Opening an app showed the header and then slid it out: the page claimed the viewport on mount, and the band animated from where it had been rendering. The slide belongs to the hover, not to arriving, so it is armed a frame after the claim — the first hide is a cut. The claim moves to the route, which mounts before the viewer waiting on the workspace to resolve. A cold load now paints the band away from the first frame instead of showing a header that leaves again. Co-Authored-By: Claude Opus 5 (1M context) * feat: an app's Edit sits with its name At the far end of the bar it was a journey across the window from the thing it edits — on a page whose header is only there while the pointer holds it, a journey with a time limit. It goes beside the app's path instead. The slot it uses is the one Runs' documentation tooltip already had, renamed from `hint` to `afterName` and documented for both: what belongs to the page's name, whether that is a mark or a control that acts on what the name points at. Co-Authored-By: Claude Opus 5 (1M context) * feat: developers collapse, operators detach One control each, rather than three for everyone. A developer switches the sidebar between icon-only and full width and never detaches it; an operator, who has no icon-only rail to collapse to, hides it instead — by the button in its footer or by the same drag that collapses it for a developer. The way back survives either way: a detached sidebar keeps its "Show sidebar" button for anyone, so a developer who detached before this was so is not left with a sidebar they cannot return. Co-Authored-By: Claude Opus 5 (1M context) * feat: configure pipeline triggers and outputs as drafts in the editor Co-Authored-By: Claude Opus 5.5 (1M context) * feat: one card for pipeline nodes, and the pipeline pages in the page header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop a removed node's trigger drafts, and confirm triggers when leaving Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show pipeline assets red when unused or awaited but never produced Co-Authored-By: Claude Opus 5.5 (1M context) * fix: route long pipeline edges around nodes in the target column, and flag scripts waiting on unproduced assets Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a page is as tall as it is given, not as tall as the screen The band takes 44px off the top of every route, so a page sized to the viewport overflows by exactly that much: h-screen inside a column that already lost the band's height. These pages measure the box they are in instead. CenteredModal gains `inPage` for the five pages that render it inside the layout rather than over it — the same modal, centred in what is left of the page rather than in the window. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band narrows, wraps, and draws its own edge The band publishes its width, so the pages filling it can answer it. Two tiers: under 1000px the breadcrumb keeps the workspace disc and its env badge and drops the name, and under PHONE_BAR (800) the trail stops yielding at 10rem — that floor is what makes the bar wrap, so a page's buttons take the line below rather than squeezing the name to an ellipsis. The band also draws the line between itself and the page, and only while something is scrolled under it. One capture listener on the window sees every scroller, since most pages scroll inside their own box and never move the layout's. An inset shadow, not a border: a border would move every page down a pixel the moment the line appeared. DetailPageLayout and InWorkspaceAppViewer drew that first line themselves; two lines would stack, so theirs go. Co-Authored-By: Claude Opus 5 (1M context) * feat: the script, app, runs and audit pages fill the band Each of these kept a header of its own above the page. They register with the band instead: the breadcrumb names what the page is on, and the page's buttons sit at the far end of the one row. They answer the band's width rather than the window's, since the buttons share that row with the breadcrumb. The script editor folds Diff and Settings into the menu it already shows when compact, the app editor drops the panel buttons, Awareness and Export and goes icon-only on Deploy and the mode switch, the flow editor menus its diff, and runs hides its counters. Runs also declares `actionsFlexible`: its search is the one control here that can give width back, and a box that refused to shrink would make it take its maximum instead. The run detail page loses its ActionRow, and the audit log its big title — the breadcrumb says where you are, and the page keeps its tooltip. Co-Authored-By: Claude Opus 5 (1M context) * feat: one sidebar column, shown as a rail or as a panel The rail and the drawer held two copies of the same column — the same sections, in the same order, in two pieces of markup, so a change to one silently skipped the other. Both now render `sidebarColumn`, and the shells keep only what differs: the rail its width, resize handle and collapse button, the panel its outside-click catcher and its transform. A phone gets that panel rather than a modal. There was a width test deciding between two presentations; the card under the band's handle works at any width, so it goes. What is left of the width test is the choice between a rail and a panel, plus a `(hover: hover)` test — the old `innerWidth >= 768` was standing in for "has a pointer", which is what decides whether the panel closes when the pointer leaves it. With the card everywhere, the backdrop, the `aria-modal` (nothing dims and nothing traps focus) and the drawer's close cross all go: the band's handle opens the panel and a tap outside closes it. The burger row goes with them. Entering panel width now collapses the rail transiently, so coming back to a wide window does not land on a rail the page was resized away from. The band also stops easing its right edge. That transition belongs to the rail, which moves the band's left; its right follows a splitter the page owns, and easing it left the band lying over a session's side panel — a blank strip across that panel's own header for 200ms after every drag. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home composer flies to the menu that restores it Removing the composer puts it away rather than throws it out, and the way back is one item in the band's menu — easy to miss on a page the composer has just vacated. A mark flies from the control that removed it to that menu, and the menu answers with one ring as the mark dissolves, so where it went is witnessed rather than explained. The cross and the menu item now read "Remove session chat", one name for one thing whichever a reader reaches for. Reduced motion skips both. Co-Authored-By: Claude Opus 5 (1M context) * fix: give pipeline asset nodes the trigger nodes' background again Co-Authored-By: Claude Opus 5.5 (1M context) * fix: taller pipeline nodes, with the asset + back on the node's edge Co-Authored-By: Claude Opus 5.5 (1M context) * fix: judge dbt relations by provenance, keep dynamic_skip on reopen, refresh saved scripts' titles Co-Authored-By: Claude Opus 5.5 (1M context) * feat: label the pipeline + buttons: add data source, add downstream step on hover Co-Authored-By: Claude Opus 5.5 (1M context) * feat: name a schedule node's cadence, like "Every day at 12:00" Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the downstream-step pill open while its menu is, without flicker Co-Authored-By: Claude Opus 5.5 (1M context) * fix: room between pipeline node chips and the node's edge Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse renaming a pipeline draft out of the pipeline's folder Co-Authored-By: Claude Opus 5.5 (1M context) * feat: title pipeline schedule nodes with the schedule's summary Co-Authored-By: Claude Opus 5.5 (1M context) * revert: keep operators out of AI sessions That change is its own PR; this one is about the navigation. `prefersSessionHandoff` takes the role again and every entry point passes it, the sessions page refuses an operator arriving by URL, and the layout leaves them on the docked chat. Two clauses are new rather than restored, because the nav rework is what gives an operator a sidebar at all: the Workspace/AI Sessions switch and session mode are both hidden from them, since the page behind them declines. `askAiShown` gains the operator case for the same reason — they keep the docked pane whatever the beta gate says, so the sidebar has to offer a way into it. Co-Authored-By: Claude Opus 5 (1M context) * feat: assets-only view of a pipeline, with producer and trigger chips Co-Authored-By: Claude Opus 5.5 (1M context) * fix: default schedule handlers, unique trigger draft paths, zoned cadence, valid script names Co-Authored-By: Claude Opus 5.5 (1M context) * fix: setup banner without icon, button actions, plainer activity rows Co-Authored-By: Claude Opus 5.5 (1M context) * feat: assets-only trigger chip under the title, editable and selectable Co-Authored-By: Claude Opus 5.5 (1M context) * fix: await schedule defaults before confirming, refuse colliding trigger renames Co-Authored-By: Claude Opus 5.5 (1M context) * feat: size assets-only cards to their content Co-Authored-By: Claude Opus 5.5 (1M context) * feat: pipeline draft sync status beside the folder name in edit mode Co-Authored-By: Claude Opus 5.5 (1M context) * fix: autosave edits to a deployed pipeline script from the first keystroke Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show schedule handlers once their defaults load, draft suffix on static chips Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the pipeline folder while the page tears down on leaving Co-Authored-By: Claude Opus 5.5 (1M context) * fix: discard the open pane's autosaved edits with Discard all Co-Authored-By: Claude Opus 5.5 (1M context) * feat: pipeline node context menus, add-at-cursor, and assets-only delete Co-Authored-By: Claude Opus 5.5 (1M context) * style: borderless pipeline cards on the main surface, like flow steps Co-Authored-By: Claude Opus 5.5 (1M context) * feat: remember which pipelines are in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) * feat: edit an asset's producing script from its pane and multi-script chip Co-Authored-By: Claude Opus 5.5 (1M context) * style: more room between pipeline layers for the bottom + button Co-Authored-By: Claude Opus 5.5 (1M context) * style: background on the trigger chip only when it opens an editor Co-Authored-By: Claude Opus 5.5 (1M context) * feat: assets-only delete in both modes, why it is blocked, and a Show scripts toggle Co-Authored-By: Claude Opus 5.5 (1M context) * fix: remove the script before its trigger, archive it for non-admins Co-Authored-By: Claude Opus 5.5 (1M context) * fix: scope assets-only memory by workspace, no delete for operators, flow reason Co-Authored-By: Claude Opus 5.5 (1M context) * style: fly in the pipeline node menus Co-Authored-By: Claude Opus 5.5 (1M context) * style: size the Show drafts toggle like the other header buttons Co-Authored-By: Claude Opus 5.5 (1M context) * feat: a No asset node for scripts that build nothing, in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) * feat: describe a pipeline edge's relationship on hover Co-Authored-By: Claude Opus 5.5 (1M context) * fix: name assets-only edges by what is built from what Co-Authored-By: Claude Opus 5.5 (1M context) * feat: open an asset's upstream trigger from its pane header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reopen the edit-mode pane when coming back from View Co-Authored-By: Claude Opus 5.5 (1M context) * style: no red border on errored pipeline nodes Co-Authored-By: Claude Opus 5.5 (1M context) * feat: Fix actions on misconfigured pipeline nodes, and trigger toggles on edges Co-Authored-By: Claude Opus 5.5 (1M context) * feat: folded errors and edge toggles in assets-only view, preset writer wizard Co-Authored-By: Claude Opus 5.5 (1M context) * feat: flag pipeline scripts nothing starts, with schedule/webhook/remove fixes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: an unwritten subscription is fixed on its script only, by removing it Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide subscribed-only unwritten assets, review fixes for the edit pane and edge actions Co-Authored-By: Claude Opus 5.5 (1M context) * fix: an unwritten subscription is the subscriber's error, not the asset's Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a script only started by reads of unwritten assets is flagged too Co-Authored-By: Claude Opus 5.5 (1M context) * feat: no add-downstream + on assets nothing in the workspace writes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: Save all treats a draft identical to the deployed script as saved Co-Authored-By: Claude Opus 5.5 (1M context) * fix: remove-from-View opens its dialog, error chip fits, no link to an undrawn asset Co-Authored-By: Claude Opus 5.5 (1M context) * feat: drag a script's dot onto an asset to subscribe it; unbuilt asset opens its script Co-Authored-By: Claude Opus 5.5 (1M context) * feat: toggle to hide reads that don't trigger runs in the pipeline graph Co-Authored-By: Claude Opus 5.5 (1M context) * feat: drag an asset's + onto a script or the canvas; tell reactive assets-only edges apart Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a programmatic on/mute line joins the existing ones in the header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: address review — live-version save check, bounded writer lookups, script-only trigger writes, dbt sources, hidden pane Co-Authored-By: Claude Opus 5.5 (1M context) * fix: review — compare every deployed field, passive-read filter only with its toggle, drag cancel, asset escape hatch Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep every control the band took over reachable Four controls the page-header conversion could put out of reach, each found by a review pass and reproduced in the browser: - The flow editor's preview buttons. `topbarWidth` is bound only in the branch a full-page editor does not render, so `compactTopbar` was permanently false there; below the 800px phone bar `phoneTopbar` dropped the buttons inline and the graph overlay never took them, leaving FlowPreviewButtons mounted nowhere and step testing, whole-flow testing and "Test flow & record" no-ops through optional calls. It now measures the header the way ScriptBuilder and AppEditorHeader do, and the inline and overlay conditions are exact complements. - The pin on a deployed app. The band lands over the corner handle that called it down, so the pointer that arrives there hits the band. The band carries the pin itself now, on the same pixels, in place of the sidebar handle it hides. - The detached sidebar's own menus. They portal to `body`, so moving into one left the card and closed it 200ms later, sliding it out from under the menu being read. The card holds while the pointer is in a menu and takes the close from the leave of that. - A deployed app's name for a read-only viewer. The breadcrumb was registered behind write access, so without it the trail fell back to the section name "Apps"; and the Edit button rode `afterName`, which the band drops for an embed along with the trail. The name is registered for whoever opened the app, and what sits after it survives `navHidden` as the page's own control. navPageFor gets a table test: it answers both the sidebar links and the breadcrumb label, and its longest-prefix and first-segment rules are what made the "Apps" fallback look like a correct answer. Co-Authored-By: Claude Opus 5 (1M context) * docs: drop what the operator revert and this refactor left behind The operator tour's last step named AI sessions among the pages the sidebar opens, which operators have no way to reach. The nav handle's comment named the AI chat layout's menu button as a second way into the card; that button and its prop are gone, and the corner control on a page that hides the band is what stands in for the handle now. Co-Authored-By: Claude Opus 5 (1M context) * style: prettier the three files this branch left unformatted `prettier --check` fails on all three at the previous head. Whitespace only, apart from one Button tag that fits on a line. Co-Authored-By: Claude Opus 5 (1M context) * feat: assets-only nodes name their script in a header tab instead of a side chip Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no loading spinner in the pane while a clicked script loads Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no subscribe drag dot in view mode Co-Authored-By: Claude Opus 5.5 (1M context) * fix: opening a draft no longer autosaves; header tab styling, selected edge, taller cards Co-Authored-By: Claude Opus 5.5 (1M context) * revert: clicking an unbuilt asset opens the asset again, not its script Co-Authored-By: Claude Opus 5.5 (1M context) * fix: header tab sets card width, edit icons on script header and trigger chip, no pane header shift on open Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no trash in the pane header; card width ignores the run chip so a click never re-lays the graph Co-Authored-By: Claude Opus 5.5 (1M context) * fix: taller trigger chip; node menu and guard badge on the card corner, below the header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: give a page without the band's chrome what the band was carrying Second review round, three reachable defects plus one decision: - A public raw app was 150px tall. The bundle's iframe is `h-full`, and dropping its `min-h-screen` left nothing to resolve against on the share, embed and custom-path routes — none of which has a layout above them. Measured: 150px in a 900px viewport. The floor goes back for those routes and stays off inside the workspace, where the layout hands a height down and `min-h-screen` would overflow it by the band. - The band retracted out from under its own open menu on a deployed app. Last round taught the sidebar card to hold while the pointer is in a portaled menu and left its sibling alone, but the band opens menus too — the workspace disc, the fork family, every path level. One helper now, used by both closers. - `bandPeeked` outlived the page it belonged to. Clicking a control in the band navigates away, and the next page may not be one the band floats over, so the hover handlers that would clear it are gone — leaving the band already down when a full-bleed page came back. `beforeNavigate` clears it. - An embed has no band to carry a control, so the app's Edit floats over the canvas there, where it sat before the band existed. Registering it as the band's `afterName` under `navHidden` instead would have cost the app 44px of the iframe for a row holding one button. Reading "is the workspace navigation hidden" now has one definition (`isMenuHidden`), since a page has to answer it too: the flag is sticky inside an iframe, so the raw query param is the wrong thing to read. Also inset the band by the rail only when there is a rail — an embed reserved its width and crowded its own controls into a compact layout early. Co-Authored-By: Claude Opus 5 (1M context) * fix: a path that names nothing yet has no breadcrumb leaf `splitItemPath` documents that it gives up on a path still being typed, but `f/demo/` is three parts and returned a leaf with an empty name — a blank segment in the trail. The editor binds the path live, so the breadcrumb sees every keystroke. Tested here rather than `navPageFor`'s sort: no two `NAV_PAGES` paths nest, so the longest-prefix case that test claimed to pin has only one candidate. Renamed it to what it actually checks. Co-Authored-By: Claude Opus 5 (1M context) * style: prettier the four remaining files this branch left unformatted All four fail `prettier --check` on the branch and pass at the merge base. `raw_apps/utils.ts` also fails but already did at the base, so it is left alone. Co-Authored-By: Claude Opus 5 (1M context) * feat: pipelines open in the assets-only view by default Co-Authored-By: Claude Opus 5.5 (1M context) * feat: a No asset node per script, banner-only when writes start it; no On asset change chip Co-Authored-By: Claude Opus 5.5 (1M context) * feat: delete a node's script, trigger, or both from its menu; red header for a script with errors Co-Authored-By: Claude Opus 5.5 (1M context) * fix: an errored script header stays red when selected Co-Authored-By: Claude Opus 5.5 (1M context) * fix: coloured run badge border; per-script delete when no combined delete; run button centred on the card Co-Authored-By: Claude Opus 5.5 (1M context) * fix: Add data source pill sits one small gap above the graph Co-Authored-By: Claude Opus 5.5 (1M context) * feat: Multiple triggers chip picks the trigger to edit; each folded trigger deletable from the node menu Co-Authored-By: Claude Opus 5.5 (1M context) * fix: add-step menu labels its path field Path Co-Authored-By: Claude Opus 5.5 (1M context) * fix: iterate NodeLists with Array.from so svelte-check passes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no trigger delete for flow-built assets; Multiple triggers chip shows the one being edited Co-Authored-By: Claude Opus 5.5 (1M context) * fix: Archive button for archive-only deletes, single delete doc, inert Multiple triggers label in View Co-Authored-By: Claude Opus 5.5 (1M context) * feat: the raw app editor fills the band like the other three It was the one editor route still rendering its own bar under the band, so it showed two header rows and the band named the app "Apps raw" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. Its top bar now becomes the page header the way the script, flow and low-code app editors' do: the path and summary are the breadcrumb's, the file-sidebar toggle, autosave cloud, presence mark, menu, Diff, Jobs, export, AI and Deploy are the header's actions, and the path is a plain label since this editor renames from its deploy drawer. The actions render under the band, outside the subtree `RawAppEditor` declares the operating workspace in, and `OpenInSessionButton` asks that context who the acting user is — so the workspace travels with them. `operatingWorkspaceContextEntry()` hands it over without exporting the key. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the folders a half-typed path already names The trailing-separator guard gave up on `f/demo/` entirely, which dropped the whole trail to the kind chip and brought `f/demo` back on the first character of the name. The levels the path does name come back now; only the leaf waits. Co-Authored-By: Claude Opus 5 (1M context) * fix: pin holds the first slot, and the nav table says what it shares Pinning brings the sidebar's handle back, and from behind it the unpin button sat one slot over from where the pin had just been clicked — so a second click in the same place opened the sidebar instead. An operator is detached by default, which makes that the default state of the feature rather than an edge case. `NAV_PAGES` claimed one table stops a page being called one thing in the nav and another in the header, which holds for the seven entries the sidebar's main group is built from and not for the four grouped separately — `/routes` was "Triggers" in the header and "HTTP" in the nav. The comment now says which is which, and that entry is named for the page rather than its group. `sidebarPageAllowed` gets a table test: it decides what an operator sees, and its `=== true` is what a future `Option`-style default would silently change. Co-Authored-By: Claude Opus 5 (1M context) * fix: the band names the path Deploy will create, and keeps the summary readable A brand-new app is parked at a `draft_` placeholder, and the band named that rather than the path its Deploy will create — `newEditedPath` refuses the placeholder for exactly this reason, and the editor's own bar read it. Both app editors register it now. Verified: the trail reads `u/admin/gentle_app` while the URL is still the draft. The band's summary gets the `title` the editor's own bar had, so one the bar truncates can still be read on hover. The wrapper that hid AppExportButton on a phone never hid anything: the component draws no trigger — it is the export drawer, which portals to `body` — so the class toggle was inert in both states and the comment read as if something moved. Dropped from both app editors. Two comments corrected: `NAV_PAGES`' `icon` does reach the breadcrumb for all eleven entries (`NavBreadcrumb` renders `routePage.icon`), so only `operatorKey` goes unread for the four the sidebar groups separately; and the raw editor's phone note described folding that does not happen — the file-sidebar toggle stays in the band, Jobs and the export are already in the menu, and AI is what stands down. Co-Authored-By: Claude Opus 5 (1M context) * fix: an item the picker files under no kind keeps its name in the band The merge brought main's agents detail page, which reaches DetailPageHeader with no `errorHandlerKind` — and the band's item required one. `WorkspaceItemKindIcon` falls through to the script glyph for anything it does not know, so defaulting would have labelled an agent a script; the kind is optional now and the trail names the item without claiming it is one of the three. Co-Authored-By: Claude Opus 5 (1M context) * fix: the file sidebar's toggle leaves the band The band is the workspace's header, not the raw editor's layout controls. ⌘B still folds the file sidebar, and the editor's own bar keeps the button where it draws one — a session pane. Co-Authored-By: Claude Opus 5 (1M context) * fix: the scroll edge stops measuring, and the comments describe the code The band's edge listener captures every scroller in the app — Monaco, the audit table, a run's logs — about once a frame while any of them moves, and it took two `getBoundingClientRect()` reads before looking at `scrollTop`. It now answers from `scrollTop` first and only measures when that answer differs from what the band draws, which is the transition rather than the steady state. The rect check still authorizes every change, so a scroller that does not start at the content's top still cannot claim the edge. The two agent routes main just added size themselves to the viewport, which is 44px more than the box the band leaves them. They measure 856 either way — the layout's `min-h-0` column clamps them — so this is the sweep the rest of the branch already had, making the height follow the box rather than depend on a parent's clamp. Six comments described the conversion rather than the code, which AGENTS.md calls out by name. Each now states the constraint a reader could break: why the summary keeps its popover here, why the audit hint is `afterName` and not an action, why a run registers a `section` rather than an item, why the Runs filters are `actionsFlexible`. Co-Authored-By: Claude Opus 5 (1M context) * Revert "fix: the file sidebar's toggle leaves the band" This reverts commit 1da8d16740b9aaef2d604acf06ac6451f2a10c28. A folded file sidebar renders nothing of its own, so the toggle on the bar is the only way back short of knowing ⌘B. Co-Authored-By: Claude Opus 5 (1M context) * feat: the editors name their item in the band, and a pen renames it The band showed each editor's summary as an inline field, which put editing where the page names what it is working on. The summary is text there now, with its `title` for one the band truncates, and the pen that opens the path for editing sits after it — the same popover the editors' own bar carries, at the end of the name it renames. Every editor already has a field for the summary itself: the script's settings, the flow's Settings tab, and both app editors' deploy drawer. The popover moves into `PathEditPopover` rather than being written out four more times. It owns the open state and hands back the path as it was when it opened: the band's trail reads that while the popover is open, so a path growing under the pointer does not drag the pen — and floating-ui the popover — along with it. `EditorHeader` renders the same component, so the session pane and the band cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) * feat: the pen renames the item and its summary The pen opened the path alone, which left the summary with nowhere to be edited from the band. It carries a Summary field above the path now — the item's name first, the address it lives at below — and the band's text follows it as the user types. `TextInput` with a label rather than the bar's inline field: `EditableInput`'s own docs rule itself out inside a popover, and it wraps this same input in its edit mode, so the field, the "Add a summary..." placeholder and the trim on commit are the behaviour that was there, in the shape the deploy drawer's Summary field already uses. The keydown stops at the field because every editor binds keys of its own and a summary is prose. `summaryEditable` carries the hosts' `customUi.topBar.editableSummary` through, and the editors' own bar passes it false: that bar keeps its inline field, and `hidePath` would take the pen with it, leaving a condensed session pane no way to name the item at all. Co-Authored-By: Claude Opus 5 (1M context) * feat: the agent editor fills the band too It was the last editor route drawing its own header row under the band, and the band named it "Agents" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. The agent's path is the breadcrumb now, its version badge reads after that name, the sentence that was the row's second line became the hint beside it, and the Chat/Form switch, Settings, Evals, version history and Deploy are the band's actions. No trail levels in the registration: this layout opens settings in a drawer and evals over the page, so unlike the dialog it never stands on a level — `trail` is its root alone here. Co-Authored-By: Claude Opus 5 (1M context) * feat: the pipeline page fills the band Found by sweeping every route under the band: the pipeline editor drew its own row beneath it, 48px of a second header, while the band named only the section. Its name is the band's now, the folder it is open on sits with that name — the switcher is how you change pipelines, so it belongs to the name rather than to the actions — and the mode switch, deploy-all, activity, refresh and overflow menu are the bar's actions. The arrow back to the list stays: nothing else on the page points there, and the sidebar does not list pipelines. Co-Authored-By: Claude Opus 5 (1M context) * fix: the app canvas controls stay reachable, and an unnamed item says so Codex found the app editor's canvas controls unreachable on an ordinary laptop: the owning route collapsed at 1350px, and a 1440 window with the rail out leaves 1232, so the width, theme and breakpoint toggles were hidden with nothing in the menu to replace them — and the breakpoint is the only way onto the mobile layout. Measured on the band, the toggles are 240px beside 334 of other actions and a ~430 trail, so they fit to about 1050: the threshold is 1100 now, and below it each of the three keeps a menu entry rather than disappearing. An item with no summary read as a bare `·` with an invisible pen after it, since the plain text replaced a field that had a placeholder. It now reads "Add a summary..." in the italic SummaryPathDisplay uses for the same state, and the pen stops hiding when there is nothing to hover. Three more from the review: - `actionsFlexible` says "these actions can give width back", which is true of the Runs filter row and not of the pipeline's row of fixed buttons. - `pathSnapshot ?? (newEditedPath || …)` keeps the `||` chain the five rename sites read and the declaration documents; `??` had made the rest unreachable, since `newEditedPath` is a string that is never undefined. - The band bridged `contexts` onto the actions alone, while the pen that decides whether a path is the caller's to change renders in `summaryContent`. The breadcrumb's `afterName` and the summary are bridged the same way now, so that permission check reads the tree that registered it. Co-Authored-By: Claude Opus 5 (1M context) * fix: the widest action rows scroll, and the canvas toggles show only where they fit Measuring the band instead of estimating it: with the canvas toggles shown the app editor's actions are 826-911px and the trail with the summary another ~500, so the row overflowed the bar at every width up to about 1470 — the 1100 I set from an estimate was wrong, and the 1350 before it was too. The toggles come out at 1500 now, and the menu entries are what keeps all three reachable below that rather than merely out of the way. The two widest rows — this editor's and the pipeline's — scroll inside their own box, which is the escape the rows they replaced had (`overflow-x-auto whitespace-nowrap`). That answers pi's finding and clears the last 23-40px of band overflow at middling widths, which predated the conversion. Measured after: zero horizontal overflow from 852px of bar to 1392. From the CI round's other findings: - The pen is offered when either the summary or the path is editable, not the path alone: a host that allowed one and refused the other could no longer edit the summary from the band. `PathEditPopover` takes `pathEditable` to match, and its label names whichever it is given. - The four editors' comments called the summary read-only, which stopped being true when the pen beside it started editing it. - The pipeline's "centre group" comment described a layout it no longer has. Co-Authored-By: Claude Opus 5 (1M context) * docs: actionsFlexible covers both ways a row gives width back It read as if a search field were the only way to earn it, which stopped being true when the app and pipeline editors' rows started scrolling inside their own box. Both are the same bargain — the box may shrink because its contents can — and the flag is what the bar reads to allow it. Co-Authored-By: Claude Opus 5 (1M context) * feat: data pipelines in AI sessions (alpha) (#11453) * feat: pipelines in AI sessions with several folders open at once Co-Authored-By: Claude Opus 5.5 (1M context) * feat: deploy and discard a session's pipeline edits as one changes-list row Co-Authored-By: Claude Opus 5.5 (1M context) * feat: pipelines in the chat's listing and the preview picker; session tab shares the page's asset prefetch Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a masked pipeline preselects only the scripts and triggers of its folder Co-Authored-By: Claude Opus 5.5 (1M context) * fix: review round — exact pipeline scripts in the fork mask, refuse a failed hand-off flush, deploy guard before awaits Co-Authored-By: Claude Opus 5.5 (1M context) * fix: review round — failed bundle save fails the deploy, removing a draft records no change Co-Authored-By: Claude Opus 5.5 (1M context) * feat: a session's pipeline tab opens node details in a modal, as the flow editor does Co-Authored-By: Claude Opus 5.5 (1M context) * feat: route pipeline graph edges orthogonally with spread ports, separate tracks and lanes Co-Authored-By: Claude Opus 5.5 (1M context) * feat: pipeline graph edges merge by shared end, enter nodes by the side, keep fewest crossings Co-Authored-By: Claude Opus 5.5 (1M context) * fix: review round — incremental crossing search and cached routes, retry the leftover draft save, drop the pipeline row when its last draft goes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: retry the leftover draft save with what failed, never an empty draft Co-Authored-By: Claude Opus 5.5 (1M context) * revert: pipeline graph edges back to bezier curves, detours curved too Co-Authored-By: Claude Opus 5.5 (1M context) * feat: hovering a pipeline graph node fades everything outside its lineage Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hover fade eases edges too, through a stylesheet instead of new node objects Co-Authored-By: Claude Opus 5.5 (1M context) * feat: gentler hover fade without flicker on click; implied trigger edges recede in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) * docs: pipeline nodes ingest straight into DuckLake or data tables from Python/TS * fix: pipeline tools callable right after open_preview; write_script refuses pipeline nodes in sessions * feat: delete a data pipeline from the home list; pipeline rows match item rows * fix: upward detour edges keep their gutter lane * fix: pipeline delete takes only its own triggers and discards draft-only nodes by storage key Co-Authored-By: Claude Opus 5.5 (1M context) * feat: AI attaches trigger drafts to the pipeline nodes that declare them; materialize nodes keep no stale output Co-Authored-By: Claude Opus 5.5 (1M context) * feat: AI gives every pipeline node a succinct summary Co-Authored-By: Claude Opus 5.5 (1M context) * fix: session pipeline tools survive another tab taking the panel, reopening the editor on call Co-Authored-By: Claude Opus 5.5 (1M context) * fix: pipeline trigger lookups skip same-path flows; a failed trigger never hides its built node Co-Authored-By: Claude Opus 5.5 (1M context) * fix: deleting a pipeline clears it from open sessions so autosave cannot restore it Co-Authored-By: Claude Opus 5.5 (1M context) * feat: data upload opens on its inputs alone, works in sessions, with a prominent Upload data button Co-Authored-By: Claude Opus 5.5 (1M context) * fix: closing the pipeline pane closes its modal instead of leaving it blank Co-Authored-By: Claude Opus 5.5 (1M context) * feat: a DuckLake asset opens on a Data tab with the database manager Co-Authored-By: Claude Opus 5.5 (1M context) * fix: inputs-only view infers draft inputs, keeps stored lineage, runs the live body Co-Authored-By: Claude Opus 5.5 (1M context) * fix: lineage follows the editor that inferred it; upload runs preview unsaved edits of deployed nodes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: readyDataUploadPaths is a ReadonlySet in the pipeline editor Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) * fix: selected pipeline nodes get their blue edge in dark mode too Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no red flash on load while writer lookups and script bodies are in flight Co-Authored-By: Claude Opus 5.5 (1M context) * fix: deploy an open deployed script's edits, and don't resurrect them after a discard Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: deploy and discard close the open pane first instead of patching its edits Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the pane closed while deploying, and clear its buffer once its edits are a draft Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a draft edited while it deploys keeps the newer edits, chained on the new version Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a draft whose summary or labels changed mid-deploy is kept too Co-Authored-By: Claude Opus 5.5 (1M context) * fix: the app viewer names the page it owns, and stops claiming the viewport A deployed app opened in an AI session's preview panel renamed the band from the session to the app, and put the app's Edit beside the session's name. The viewer registered wherever it was mounted; it takes `ownsPageHeader` now, the way the detail pages already did, and keeps Edit over its own canvas otherwise. Both viewers also claimed the whole viewport under a band that takes 44px of it, so a deployed app scrolled by exactly the height of its own header (the low-code one measured 900px in an 856px box). The height comes from the host now: the three standalone routes floor themselves at the viewport and everything inside them is `h-full`. A raw app reached through `/app_embed` had no height above it at all and collapsed to nothing; it fills the frame now, though that route still fetches no bundle secret, so what it shows is blank — as it is on main. The agent editor's pen was a second editor of the same path that reported nothing: an invalid path never reached the deploy gate the settings drawer's field reports to. It binds to the same slot now. Two things that surfaced with it: a rename never reached the trail, which read the deployed path; and typing the saved path back raised "path already used", since the popover's `initialPath` moves with the rename — `allowedExistingPath` is what says the item already occupies it. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the band's controls come from the design system, and the row has one copy `NavBreadcrumbTrigger` was `MeltButton` written again — same `use:melt` on a plain button, same spread — so the workspace chevron uses the component that already exists, and the sidebar handle is a `Button`. `DetailPageHeader` carried its action row twice, once for the band and once for the row it draws inside a session's preview panel, and the two had already drifted: different dropdown size, no `unifiedSize` on the buttons, and the deprecated `variant="contained" color="light" size="xs"` on the schedule button. Both placements render the same two snippets now, at the size each needs. `OperatorPageKey` admitted every `operator_settings` field, including the three write rights, which `sidebarPageAllowed`'s `=== true` rule answers wrongly: a write right is granted unless withdrawn, so its absence means "never configured". It is the ten visibility flags now. A test over the registry's merge: later registration wins field by field, actions sort by `actionsOrder` then registration, release gives its fields back. That rule is why a component registering where it is not the page renames someone else's band. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band owns its bottom edge, and one pen renames everywhere The page header decides whether a separator is drawn, so a page cannot draw a second line under it. A registration asks for `onScroll` (the default, drawn only while something passes under the bar), `always` for a page whose content starts right beneath it, or `none` where the page genuinely draws its own first line. Detail pages, the agent editor, workspace settings and the pipeline list take `always`; the raw app editor takes `none`, since its splitpanes already draw that line. The pen that opens the rename popover becomes PathEditPen, shared by the editors and the deployed pages so the two cannot drift, and the deployed pages gain it. SummaryPathDisplay makes the pen the only trigger rather than the whole name block, so the name is selectable text again. Renaming an agent goes through ResourceService with only the fields the popover edits: the backend sets a column per field it is given, so writing the config back would clobber a concurrent edit. The popover also stops clearing a real collision verdict by telling Path which path is its own. Two Button fixes, both reaching call sites beyond this work: - `dropdownItems` given as a function was asked for `.length`, which is its arity and therefore 0, so a split button built that way rendered no caret. Both app editors' Deploy dropdowns were unreachable. - the anchor branch dropped `title`, so a link button had no tooltip. The audit log filters move into the header actions at the design system's sizes and trade absolutely positioned labels for the form layout in brand-guidelines.md: a label above its input, 4px apart, fields 8px apart. The deployed app viewer floors itself at `min-h-full` instead of claiming the viewport, which would overhang the page box by the height of the band, and carries Edit and a public-url link that fold into a menu on a narrow bar. The root layout drops the legacy menu block, which rendered a second hidden sidebar and 54 invisible tab stops, and marks the closed detached sidebar `inert`. Co-Authored-By: Claude Opus 5 (1M context) * feat: service logs and the audit filters move into the band Service logs: the page's name is the breadcrumb's, so the `

` is gone and the search field, the timeframe and the two switches sit in the band beside it, with the documentation tooltip after the name. ServiceLogsInner registers the band itself, the way RunsPage does, since that route is its only caller, and the `title` snippet the page used to hand it goes with the heading. The search is the widest control there, because searching is what the page is for, and it is the one that gives width back under pressure. The two switches fold into a popover below a 1200px bar rather than at a phone's width, so the field keeps its full width down to a much narrower window — folded rather than dropped, because the page no longer carries a toolbar they could fall back to. The timeframe control took no size of its own, so it defaulted to `md` beside a `sm` field, and sat in a `w-48` box 20px narrower than its content, whose overflow is visible and so spilled into its neighbours. No `separator`: the two panes below start under the bar with borders of their own, so at rest there is nothing for the band to divide. Audit logs: the filters lose their labels. Each select says what it filters while unset — "all usernames", "all resources" — and once set the value names the field by itself. From and To become the TimeframeSelect the runs page uses, which absorbs the standalone Refresh button, since that control's left half is the refresh. `auditTimeframes` joins the runs and service-logs presets, with "All time" first. The bounds stay the filters' own `after`/`before` rather than a remembered preset label: the timeline's zoom writes them too, and a label could then contradict the chart. That takes the row from 1326px to 898px, so it stays inline down to a ~1500px viewport instead of ~2500. The fold measures the row against the bar less what the breadcrumb takes, plus a gap the two must never close — the breadcrumb yields width grudgingly and the row not at all, so sized to just meet they overlap rather than compress, and mid-resize the tooltip after the page's name lands on the timeframe button. Co-Authored-By: Claude Opus 5 (1M context) * fix: an embedded page keeps its name in the band A page in a session's preview panel loads in a frame that sets `nomenubar`, so the layout renders the band with `navHidden`. That flag hid the whole breadcrumb — which is what carries the page's name — while the actions beside it had no such guard, leaving a row of controls belonging to nothing. Hiding the trail is still right: it leads to the host's workspace and offers a picker that would take the reader out of the session. So the breadcrumb gains a `nameOnly` mode that drops the workspace part and its picker and keeps the page's own name, and the band asks for it instead of rendering nothing. The summary line loses the same guard, so an item in there shows its path and summary. Only the pages whose own title the band took over are affected. Variables, Resources, Schedules and Triggers still draw their own `PageHeader` and render no band when embedded, so nothing is doubled. The bar's left padding is there to sit a button against — the sidebar handle, or the workspace disc, both of which carry their own margin. Embedded there is neither, and the name started as bare text 8px from the edge, so that case takes the same 16px the bar already keeps on its right. Workspace settings drops the copy-id button from the band: the General tab names the workspace ID with a field of its own, which is where someone looks for it. Co-Authored-By: Claude Opus 5 (1M context) * feat: a session in full screen keeps its tabs in the band Full screen used to drop the session's name from the breadcrumb and leave the tab strip inside the panel. The band now carries both: the session's name where "Sessions" stood, and from the breadcrumb to the right edge one filled surface holding the tabs and the preview's controls, the bar's full height. The switch between the two layouts is the panel expanding to the left. The chat column is resized to nothing rather than unmounted, so it can be seen taking its place -- and so a toggle no longer tears down every mounted chat and rebuilds it on the way back. Panes transition their width only while that switch is in flight, since a splitter drag sets the same inline width and would trail the pointer. The band floats over the content in full screen as it already did beside a panel, so only its right edge moves and the page below never shifts by the bar's height. Leaving full screen, the panel travels back across ground the band has already claimed, so it passes one layer above it for the length of the move: pinned to the width the panel will end at, the bar would otherwise show through as a white patch over the panel's left edge. The breadcrumb also gains the acting fork, the way every other page shows it, which replaces the "Acting on" control the session header carried. Co-Authored-By: Claude Opus 5 (1M context) * fix: the band's inset survives a resize made in full screen The panel was remembered as a width, sampled while it stood still. A window resized during full screen left that width describing a layout that no longer existed, and the way out pinned the band to it for the length of the move. It is now remembered as the share of the row the panel takes, which the new row width turns back into the right inset: leaving full screen at 1200px after entering it at 1600px, the band starts at 628 rather than at 909. The pane transition's duration is read from the property the timer is set from, so the class cannot outlive the movement it describes, and two comments that described earlier drafts are rewritten: the band's name in full screen comes from the session's own header once it has mounted, and the tab strip's wrapper carries a fade, not a measured move. Co-Authored-By: Claude Opus 5 (1M context) * fix: an agent's pen keeps the labels the agent already carries The pen on an agent's page was handed the summary and the path but not the labels, so the popover offered an empty list over a resource that had some. It saves every field it shows in one write, and the resource endpoint sets the column it is given, so adding a label there replaced the agent's own: an agent labelled `production` and `scheduled` came back from one edit labelled only with the label just added. A flow's page and a script's page both pass theirs; this one now does too. Verified against the running instance: an agent created with `["production","scheduled"]`, one label added through the pen, reads back `["production","scheduled","new"]` — and `["new"]` with the prop removed again. Co-Authored-By: Claude Opus 5 (1M context) * fix: the sidebar remembers detaching per role, not per account Whether the sidebar starts detached depends on the role: an operator gets it out of the way, a developer gets it docked. That default held only until the control was pressed once, because the choice went to a single key read before the role. From then on both roles wore whichever was pressed last, and the one that had not asked for it could never get its own default back — switch a workspace where you are an operator for one where you are a developer, and the sidebar stayed detached. Each role now keeps its own choice, and falls back to its own default until it has one. Co-Authored-By: Claude Opus 5 (1M context) * docs: the handle's comment describes what the handle does It claimed a click holds the sidebar card open. Nothing distinguishes a click from a hover there — both call the same opener, and the pointer leaving schedules the close either way — so on a desktop the card follows the pointer and goes when it goes. Measured: hovering slides the rail from -213 to 5, the click moves nothing, and the pointer leaving puts it back at -213. Co-Authored-By: Claude Opus 5 (1M context) * feat: a dev workspace's badge wears its fork's own colour Every surface that names a dev workspace drew the same blue badge, which said nothing about which workspace it belonged to. One component, DevWorkspaceBadge, now draws it: a fork with a colour fills the badge with that hue and writes the label in white, and a fork without one keeps whatever its row had before. The hue cannot carry white at a fixed lightness — HSL lightness is not perceived brightness, so yellow and purple at the same L differ by several stops — so forkAccentStyle solves the badge's lightness by bisection instead, taking the lightest shade of the hue that still holds white at WCAG AA. Measured across the wheel the contrast lands between 4.52 and 4.64. The session picker opens with every family collapsed, since the fork a session runs in is the session's own and not something that picker should offer to change. Co-Authored-By: Claude Opus 5 (1M context) * feat: the name is what opens its rename editor, and the trail truncates from its start The pen beside a name is gone. The name itself is the trigger, with a hover fill behind it where the click opens an editor rather than going anywhere — the path segment that only copies keeps no fill, which is what tells the two apart. A bar with a summary field of its own (EditorHeader's condensed pane) still gets the pen, since it has no name to hang the trigger on. Clicking either half of a name opens the editor under that half with the cursor in the matching field. The band draws the path and the page draws the summary, so one popover cannot anchor under both: a page hands the band a `pathTrigger` snippet and the band renders its path segment inside it, passing the classes its own segment wears. Each editor supplies a second PathEditPopover for it, and `focusField` names the field to focus — `#path` explicitly, since melt otherwise lands on the row's first button. Those snippets render from the band's tree, which outlives the values they read by a flush, so each is gated on the value it reads. Without that an agent page whose draft had not loaded threw inside the band and left the breadcrumb empty. The trail now carries two weights: the segment that ends it is what the page is about and takes `font-medium text-emphasis`, everything before it is a step quieter. Which segment that is follows the route — the workspace on home (the fork alone inside a fork), a list page's own name, an item's summary. And it truncates instead of switching to icons at a breakpoint. The workspace part yields first, the family name before the fork, then the path's folders, and the item's own name last. The bar no longer wraps on a phone: a wrapping row never squeezes anything, it hands the trail a line of its own and leaves it whole. Co-Authored-By: Claude Opus 5 (1M context) * fix: a page keeps its path segment when it cannot offer a rename Four things the local reviews caught in the rename work: A detail page handed the band a `pathTrigger` whether or not it could save one. Without `onSaved` — every script, flow or agent page the reader cannot write — `SummaryPathDisplay` fell through to its read-only branch, which ignores the `label` it was given and draws its own block: the trail lost its path and the summary appeared twice, two lines deep, in a 44px bar. The page now offers the trigger only where a rename can be saved, and the component renders what it was handed rather than its own row. The band gives up its own segment the moment a page offers a `pathTrigger`, so a trigger whose own gate is closed left the trail with no path at all — for the whole of an agent draft's load, not just a flush. Each gated snippet now draws the path itself in that case. `openFocus` named the path field as `#path`, which melt resolves with a document-wide query; `Path` hardcodes that id at every call site, and the popover is portalled to the body, so it lost document order to any other `Path` on the page. It is scoped to the popover's own field now. And `open` was an exported `$bindable(false)` on an optional prop, which the frontend's own rules ban. No caller bound it, so it is local state. Co-Authored-By: Claude Opus 5 (1M context) * fix: the badge solves its lightness against the colour the browser paints Three nits from the review round. The contrast bisection measured the hue at full float precision, but a browser paints 8-bit channels: rounding them moved the result up to 0.025 off the target, which took #993388 to 4.478 against white — under the AA floor the comment claims. Quantizing the channels inside `paintedLuminance` puts the worst case across 2880 hue and saturation combinations at 4.500, and your own fork's badge measures 4.53 as painted. An agent nobody can write still offered the rename behind its path segment: an editor with two read-only fields, in place of the click that copies the path. It is gated on `readOnly` now, the way a detail page is gated on `onSaved`. The script and flow editors take the same gate from the `customUi` flags that already decide whether their summary offers one — unreachable today, since the editors own the band only on routes that pass no `customUi`, but the four editors now answer the question the same way. And the registry's doc named an `onEditName` field that has never existed. Co-Authored-By: Claude Opus 5 (1M context) * fix: an operator is not offered a fork of a workspace A fork does not carry the role. The server writes the forker's membership row in the new workspace from the parent's `is_admin` and lets `operator` fall to its `false` default, so an operator who forks lands in a full-rights copy of the workspace they could only run things in. `canCreateFork` refuses an operator, which takes the entry out of the workspace picker, the session family picker and the session changes bar; `editInForkAllowed` refuses one too, so no editor offers "Edit in fork". The fork page refuses on its own, since a URL reaches it whatever the pickers show. And a workspace the user is an operator in is no longer a base the fork form will accept, so the base picker cannot route around the gate. This hides the affordance; it does not enforce the rule. `create_fork` asks only for membership — `require_admin` covers the dev-workspace case alone — so the API still answers an operator who calls it directly. The server-side fix is to carry `operator` into that membership row, the way the member-copy path already does, and it belongs in a backend change rather than here. Co-Authored-By: Claude Opus 5 (1M context) * style: the fork refusal uses the design system's alert The refusal panel was hand-built from a div and two paragraphs with literal colors, copied from the Runs page's own operator refusal. The frontend's rules say to use the design-system component, and `Alert` already draws exactly this. Co-Authored-By: Claude Opus 5 (1M context) * fix: frontend type errors failing npm_check The dev workspace badge accepts the nullable label the API returns, the session strip transitions take the node Svelte passes them, and an unused import is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: the frontend type-checks again `check frontend build` has been red on every head for a day, through four review rounds that each came back clean — none of the reviewers type-check, and they say so in their own coverage sections. Ten errors: The dev badge's `label` and `color` were typed `string | undefined`, but the workspace rows carry `string | null`, so all seven of its call sites failed. `forkAccentStyle` took the same widening. A Svelte transition is called with the node it animates, and `stripArrive` and `stripLeave` were declared with no parameters at all. And `DevWorkspaceSetting` still imported `Badge` after its badge moved into the shared component. `svelte-check` now reports 0 errors. Note that it only agrees with CI once `src/lib/gen` has been regenerated — the gitignored client goes stale against main and invents errors CI never sees. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home bar carries the CLI and hub buttons while it has the room A menu is where a thing goes when the bar cannot hold it, not where it belongs. Above 1024px of bar, "CLI / MCP" and "Hub" stand as their own buttons; below it they fold back into the ellipsis, which is still where they live on a phone. The threshold reads `pageHeader.barWidth`, not the viewport, so it tracks what the sidebar actually leaves behind. Home's trail is ~250px and its other registered actions ~400, so the two labelled buttons fit from about 850 up; 1024 leaves margin before the trail would start truncating, and it does not truncate at any width between 760 and 1600. The ellipsis hides itself when it would open an empty menu, which happens only once both side trips are in the bar and the composer is already on the page. Co-Authored-By: Claude Opus 5 (1M context) * feat: a fork's band counts its drafts beside its drift, and every count opens what it names A fork that had drifted stopped saying anything about its drafts: the banner printed the draft count only while the fork was in sync, and the drift badges replaced it the moment there was anything to deploy. A fork can be 86 ahead, 39 behind and holding 128 drafts; it now says so. All four counts open the page that holds them — ahead the deploy direction, behind `?dir=update`, conflicts the side they are resolved from before deploying, drafts the drafts half. The button beside them still chooses a direction on the reader's behalf; a badge means the one thing it names. The colours and the icons are the compare page's, so the counts in the bar and the rows behind them say the same thing the same way: ahead green, behind blue, conflicts orange. Drafts share blue with behind and are told apart by a pencil and by naming what they count, rather than by an arrow that would read as a third direction. Both arrows are straight, here and on the compare page. "Review changes" replaces "Review & Deploy Changes", and it renders in every state. What the comparison has to say — still counting, or failed — is a mark beside the button rather than a branch that replaces it: a fork whose count is unfinished is exactly when a reader wants the page that counts properly. The drafts banner outside a fork drops "This workspace has", so the count reads the same wherever it appears. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home band keeps its hero's own cross, and the side trips stay quiet The CLI and hub buttons take the shape the hero gave them before the bar did — `xs`, hint-coloured, the hub keeping its outbound mark — so a side trip does not read as loudly as the thing the page is for. The ellipsis is gone while the hero is on the page: the hero's cross already puts it away, so a menu whose only item repeats that is a button that does nothing new. It comes back when it has something of its own to carry, which is the side trips on a narrow bar and the way back to a hero that has been put away. That entry is now "Pin session hero to homepage", and "Remove session chat" survives only as the cross's own words, where one action keeps one name. Co-Authored-By: Claude Opus 5 (1M context) * feat: a fork's counts and the way to review them are one control Four badges beside a button read as four things that happen to sit together. They are one thing — every segment opens the same page on the part it names — so they now share a box: one outline, square corners inside it, and the rounding on the box alone, which means no segment has to know where in the row it sits. The only rule in the strip is the one before the end cap: everything left of it is a reading, that is an action. With room every count says what it counts; below 1280px of bar they keep their icon and their number, which is the part a reader scans for, and the tooltip names them either way. The end cap keeps its words at every width — the counts have icons to fall back on and it has none. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page asks once, says it is working, and knows its own name The page re-requested the same summaries and on-behalf-of emails forever. Two faults compounding: `fetchOnBehalfOfInfo` skipped a key it had already answered with `onBehalfOfInfo[k] !== undefined`, but stored `undefined` on failure — so "asked, and there was nothing" could not be told from "never asked". An item that is only ahead does not exist in the parent, so that side always fails: a permanently un-cacheable set, re-requested on every pass. And the effect that drives both fetchers depended on what they write. Each reads its cache synchronously before its first `await`, which registers that cache as a dependency; the write that follows re-runs the effect. Write, re-run, write. Both now record what they asked for in a plain Set, marked before the request rather than after the answer, and the effect is untracked so it depends on the comparison alone. The caches are cleared when the compared pair changes, since a summary's key carries no workspace and those Sets would otherwise never ask again. While the comparison was in flight the page printed "No changes between this workspace and X" — an empty list and an unanswered one look alike from the layout, and that is a claim with no answer behind it. The page tracks the request now, and the layout spins instead. The breadcrumb called the page "Forks", which is what `navPageFor` falls back to when a route names itself nowhere: the first segment, capitalised. It registers "Compare" instead — in the page rather than in NAV_PAGES, which is what the sidebar renders from. Co-Authored-By: Claude Opus 5 (1M context) * Revert "feat: a fork's counts and the way to review them are one control" This reverts commit e4504d2843. The counts go back to separate badges and "Review changes" to a button of its own. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page follows the fork picked in the trail Switching a fork from the breadcrumb left the page comparing the pair it opened with. Two reasons, neither reactive: the page seeds `currentWorkspaceId` from `?workspace_id` once at mount and never reads it again, and the switch rewrites `?workspace`, a different param that this page does not read. It follows `$workspaceStore` now — picking a fork in the trail means comparing that fork. The param is rewritten so a reload keeps the pair on screen, and an arbitrary `?target=` is dropped, since it named a destination for the workspace just left. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band is a step taller, and the runs row sizes with it The bar goes from h-11 to h-12, taken from one constant beside the top padding a floating band owes the page under it, so the two cannot drift. The runs page's controls now share the sm height the rest of a header's actions use: FilterSearchbar learns the size, dropping the top margin that only centres text at md and taking the shared leading instead, and sm buttons get px-3 so a label reads as a button. The actions sit in their own row with a wider gap, and the chart below meets the header with no gap of its own. The search field holds one width through typing. As flex-1 it was whatever the row had left over, so committing a chip grew it from 226 to 288 and took those pixels off the breadcrumb. A fork's count badges keep their labels while the bar is at least 1280 wide and fall back to icon and number below that. Co-Authored-By: Claude Opus 5 (1M context) * fix: the trail holds a path's place instead of naming its kind A page registers its kind when it mounts and its path once it has loaded one. In between the trail printed "script" or "flow", which reads as the answer and then jumps when the real path lands. A skeleton of a plausible path's width holds the place, with aria-busy and a label naming what is loading. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page reads the workspace it was linked with The effect that follows the trail's fork also ran on mount, so a `?workspace_id` that differs from the navigation workspace was thrown away. That difference is the contract: a session's Review button passes the fork it committed to and leaves the navigation workspace alone, as does the prod -> dev link in UpdateDevWorkspaceModal. Both opened on the sidebar's workspace instead, comparing the wrong pair or none at all. Only a change in the store now retargets the page, measured against what it held when the page opened. The URL rewriting goes with it. The picker's items are links that carry every other param forward, so their navigation lands after the effect and put back the `workspace_id` it had just rewritten: switching fork and reloading left the trail naming one workspace and the page comparing another. The page reads `?workspace=` ahead of `?workspace_id=` instead — the param the picker writes and the root layout already applies to the store — and `?target=` lapses while the page is on another workspace rather than being deleted from a URL someone else is about to replace. Two 44px literals left over from the h-11 band: the detached sidebar card's measured-top fallback and the rail morph's end state, both now from BAR_HEIGHT_PX beside the class the band wears. The runs search field keeps its 18rem without growing, but as `shrink-0` nothing in that row could give width back, so a short bar had no answer but the trail's truncation and an overlong row would have run past the edge. `min-w-0` restores the yield without restoring the growth: a flex item's floor is its min-content width, which the chips inside the field raise. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page's target picker works, and a switch survives reload `?target=` was gated on the page still being on the workspace the URL named. Opened from "Merge into another workspace" in the workspace settings the URL names no workspace at all, so that comparison was ` === undefined` and the param was ignored for good: picking a target left the page on the "no parent workspace" prompt, which is the only place the migration can be started from. Read the param as it was read before. The follow effect writes `?workspace=` again. A plain click in the picker preventDefaults its own link and switches the store directly, so nothing navigates afterwards and nothing overwrites the write — while `fixupUrlAfterWorkspaceSwitch` rewrites the param only once it is already there, which on a page opened with `workspace_id` alone it never is. Without it, switching fork and reloading left the trail naming one workspace and the page comparing another. Exercised on a fork pair: the settings entry's picker loads the comparison it names, a plain-click switch survives a reload, and a `workspace_id` deep link still outranks the navigation workspace. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the compare page reads its pair instead of following it Net effect count: three removed, none added. The page derives the workspace it compares from the URL and the store rather than keeping it in state and running an effect to follow a switch. The workspace-switch link is what moves it: the picker's items carry every param forward, so a page-side rewrite raced that navigation and lost — the trace showed the fixup's replaceState landing first and the link's pushState putting the old params straight back. The href now swaps `workspace_id` alongside `workspace` and drops `target`, and `fixupUrlAfterWorkspaceSwitch` does the same for the paths that do not navigate, so both writers agree and the order stops mattering. Dropping `target` on a switch also closes the case where switching onto the workspace that was the target left the page comparing it with itself. CompareWorkspaces remounts on either side of the pair changing, not just the target: two forks of one parent shared a mount, since the target is the parent they have in common, and with it the selection, the deploy direction, the summaries, the pinned rows, the deploy permissions and the CI results. The cache-clearing effect that stood in for that is gone. Its summary loops outlive their mount, so they read the pair once at the start and stop writing when the instance is destroyed. The app viewer asked for a public secret on every app view to decide whether to draw a button; it reads the mode off the app it has already loaded and fetches the secret when the button is used. The summary popover seeds its fields on the open transition rather than from an effect that also read the item: a save landing while it was open reseeded the fields over what was being typed. Exercised against a running instance, 18 cases: idle issues nothing, a switch moves page, trail and URL together and survives a reload, a deep `workspace_id` link still outranks the navigation workspace, four fast switches settle with no foreign row, and the migration target picker loads the pair it names. Co-Authored-By: Claude Opus 5 (1M context) * fix: the public url opens the tab the click itself opened The secret is fetched when the button is used, and the tab was opened once it arrived — after an await, where Safari no longer counts the click as the gesture that opened it and blocks the popup without saying so. Open the tab synchronously and point it at the url once the secret is known, closing it and saying why when there is none. Co-Authored-By: Claude Opus 5 (1M context) * fix: the public url button survives a sandboxed app A sandboxed low-code app loads inside the opaque /app_embed iframe, so `loadApp` never runs on this page and `app` stays undefined — taking the button and its compact-menu entry with it, for the owner too. The mode now comes from the lite read that already runs for the Edit button, which is there for exactly this reason: it is the one description of the app this page has whether or not the app itself loads here. Exercised on four apps: anonymous sandboxed low-code, anonymous unsandboxed low-code and an anonymous raw app all show the button and fetch the secret only on click; a publisher app shows neither and asks nothing. At 640px the button folds into the menu, which carries it. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the removed hero leaves a blue flash on the ellipsis The mark that flew from the cross to the menu is gone, with its two element refs, its flight state and timer, and the `data-home-ai-menu` hook that existed only as its destination. What is left is the thing it was there to say: the ellipsis holds the way back, so it flashes blue as it appears. Written as a keyframe animation, not a transition. A transition needs the element painted at its start value before the end value is set, and this mounts in the same flush that collapses the hero, so the frame that would have shown it at rest is the frame it is already told to leave — one `requestAnimationFrame` is not reliably past that paint, and the flash was never drawn at all. `animate-fade-out` already exists for this (AssetsDropdownButton flashes a background the same way); mounting it only while pulsing gets a fresh run per removal without that component's reflow juggling. The flash rides the ellipsis rather than the row: with the CLI and hub buttons beside it the row is three controls wide, and marking all three names none of them. Co-Authored-By: Claude Opus 5 (1M context) * fix: the flash rests invisible, and its comment describes what is there `animate-fade-out` carries no fill mode, so at the end of the run the element returns to its own opacity. At the default 1 that is the flash coming back at full strength for as long as the unmount timer lags the animation — which the hero's collapse, happening right there, is well placed to cause. `opacity-0` at rest makes the revert land on invisible. The comment above it still described the mark that used to fly to the menu, and made its point three times over. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page asks only about the pair it is showing Switching workspace on this page fetched the previous pair's item paths from the new workspace, where none of them exist: 97 of 174 requests 404ing on one switch. The comparison was plain state, and the card is remounted when the pair changes — in the same render that changes it, before the effect that cleared the comparison could run, so the new mount started from the old pair's diffs. It now carries the pair it describes and is read only while that matches, which leaves nothing to clear. `computeFullScan` had the same shape: it read the target again after its await, so a switch mid-scan invalidated another pair's cache, named the wrong workspace in its toast, and left the scan button spinning on a pair that was never scanned. It captures the pair up front, re-compares only while the page is still on it, and reports it as its own. Each diff says which side holds the item, so the summary and on-behalf-of loops stop asking the side that does not: an item that is only ahead cannot answer in the parent, and asking was a guaranteed 404 and a console error per one-sided row. Those two loops also read the same object — a summary and an identity are fields of one payload — so every row was fetched twice per workspace. One read per item per workspace now serves both. A failed read is not kept: held, it would read as "this item has no identity", which is what decides whether a deploy offers to preserve one, and an item deployed without it silently becomes the deployer's. The header names the workspace being compared when that is not the one the app is pointed at, which is how a session's Review button and the prod -> dev link open this page. The trail said the parent while the page compared the fork. Measured on a 90-item pair, before -> after: a cold load 247 -> 170 requests and 167 -> 90 item reads; a workspace switch 174 requests with 97 404s -> 71 with none. Sixteen cases over cold load, idle, switching in both directions, four switches with no time to settle, a cross-family switch, reload, deep link, the migration picker and leaving an arbitrary target: no request in any of them names a pair the page has left. Co-Authored-By: Claude Opus 5 (1M context) * fix: the scan toast reports the pair it scanned Off the scanned pair the re-compare is skipped, so there is nothing to report as compared — but the success branch said "Compared N items" anyway, and the error it checked first belonged to whichever pair the page had moved to. Each outcome now says what happened: the seed alone when the page has moved on, and the comparison's result only where one was asked for. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Guilhem Lemouel Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ruben Fiszel --- cli/src/guidance/skills.gen.ts | 42 +- .../lib/components/AutosaveIndicator.svelte | 8 +- .../src/lib/components/CronBuilder.svelte | 2 +- .../src/lib/components/DatatablePicker.svelte | 9 +- .../src/lib/components/DucklakePicker.svelte | 9 +- frontend/src/lib/components/Editor.svelte | 3 + .../src/lib/components/ScriptEditor.svelte | 71 +- .../lib/components/SummaryPathDisplay.svelte | 23 +- .../AssetGraph/AddDataSourceMenu.svelte | 150 ++ .../AssetGraph/AddDownstreamMenu.svelte | 73 + .../AssetGraph/AddDownstreamPill.svelte | 23 + .../assets/AssetGraph/AddNode.svelte | 167 +- .../assets/AssetGraph/AssetGraphCanvas.svelte | 1229 ++++++++++++- .../AssetGraph/AssetGraphDetailsPane.svelte | 387 ++-- .../assets/AssetGraph/AssetGraphEdge.svelte | 54 +- .../assets/AssetGraph/AssetNode.svelte | 737 ++++++-- .../AssetGraph/DucklakeAssetPanel.svelte | 44 +- .../assets/AssetGraph/InitialFitView.svelte | 4 +- .../assets/AssetGraph/NodeActionsMenu.svelte | 68 + .../assets/AssetGraph/NodeFixButton.svelte | 124 ++ .../assets/AssetGraph/NodeOnDot.svelte | 23 + .../assets/AssetGraph/PanToNode.svelte | 5 +- .../AssetGraph/PipelineActivityPanel.svelte | 8 +- .../AssetGraph/PipelineDbtSignpost.svelte | 40 - .../AssetGraph/PipelineDeployErrors.svelte | 49 + .../PipelineDeployTriggersModal.svelte | 47 + .../AssetGraph/PipelineFolderList.svelte | 93 +- .../AssetGraph/PipelineGraphEditor.svelte | 447 ++++- .../AssetGraph/PipelineInsertMenu.svelte | 501 +++++- .../assets/AssetGraph/PipelineNodeCard.svelte | 174 ++ .../AssetGraph/PipelineScriptView.svelte | 41 +- .../AssetGraph/PipelineSetupSignpost.svelte | 83 +- .../AssetGraph/PipelineTriggerEditors.svelte | 102 +- .../assets/AssetGraph/RunStateChip.svelte | 37 + .../assets/AssetGraph/RunnableNode.svelte | 284 ++- .../assets/AssetGraph/TriggerNode.svelte | 352 ++-- .../AssetGraph/assetGraphLayout.test.ts | 14 + .../assets/AssetGraph/assetGraphLayout.ts | 68 +- .../assets/AssetGraph/assetNodeWidth.ts | 66 + .../assets/AssetGraph/assetsOnlyFolders.ts | 25 + .../assets/AssetGraph/assetsOnlyView.test.ts | 162 ++ .../assets/AssetGraph/assetsOnlyView.ts | 234 +++ .../assets/AssetGraph/edgeDescription.test.ts | 51 + .../assets/AssetGraph/edgeDescription.ts | 110 ++ .../assets/AssetGraph/graphTraversal.test.ts | 43 + .../assets/AssetGraph/graphTraversal.ts | 73 + .../AssetGraph/onDirectiveTargets.test.ts | 22 + .../assets/AssetGraph/onDirectiveTargets.ts | 34 + .../AssetGraph/pipelineAiHelpers.test.ts | 53 +- .../assets/AssetGraph/pipelineAiHelpers.ts | 122 +- .../pipelineAnnotationEdits.test.ts | 66 + .../AssetGraph/pipelineAnnotationEdits.ts | 149 ++ .../pipelineAssetPrefetch.svelte.ts | 164 ++ .../AssetGraph/pipelineDataUploads.svelte.ts | 87 + .../assets/AssetGraph/pipelineDelete.test.ts | 52 + .../assets/AssetGraph/pipelineDelete.ts | 135 ++ .../AssetGraph/pipelineDeploy.svelte.ts | 230 +++ .../assets/AssetGraph/pipelineDeploy.test.ts | 56 + .../AssetGraph/pipelineEditorState.svelte.ts | 142 +- .../AssetGraph/pipelineEditorState.test.ts | 69 + .../AssetGraph/pipelineTriggerDraftDeploy.ts | 89 + .../AssetGraph/pipelineTriggerDrafts.ts | 26 + .../assets/AssetGraph/resolveGraph.test.ts | 233 ++- .../assets/AssetGraph/resolveGraph.ts | 198 ++- .../lib/components/assets/AssetGraph/types.ts | 30 + .../assets/AssetGraph/upstreamScriptItems.ts | 32 + .../lib/components/common/alert/Alert.svelte | 21 +- .../common/contextmenu/fromMenuItems.ts | 18 + .../common/table/PipelineRow.svelte | 155 ++ .../copilot/chat/AIChatManager.svelte.ts | 81 +- .../copilot/chat/AIChatManager.test.ts | 22 +- .../lib/components/copilot/chat/anthropic.ts | 10 +- .../lib/components/copilot/chat/chatLoop.ts | 3 +- .../components/copilot/chat/global/core.ts | 47 +- .../chat/global/globalAssembly.test.ts | 19 +- .../copilot/chat/global/globalAssembly.ts | 11 +- .../chat/global/sessionToolset.test.ts | 4 +- .../copilot/chat/openai-responses.ts | 10 +- .../copilot/chat/pipeline/core.test.ts | 266 ++- .../components/copilot/chat/pipeline/core.ts | 342 +++- .../components/copilot/chat/shared.test.ts | 24 + .../src/lib/components/copilot/chat/shared.ts | 24 +- frontend/src/lib/components/copilot/lib.ts | 5 +- .../graph/renderers/nodes/NodeWrapper.svelte | 17 +- .../components/home/CreateActionsMenu.svelte | 218 ++- .../src/lib/components/home/ItemsList.svelte | 13 +- .../src/lib/components/home/TreeView.svelte | 25 +- .../sessions/PipelineEditorView.svelte | 301 +++- .../sessions/PreviewRouterPicker.svelte | 32 +- .../components/sessions/PreviewTabHost.svelte | 5 +- .../sessions/SessionChangesBar.svelte | 9 +- .../sessions/SessionDiffDrawer.svelte | 9 +- .../components/sessions/SessionPicker.svelte | 4 +- .../sessions/modifiedItemsMask.test.ts | 6 + .../components/sessions/modifiedItemsMask.ts | 16 + .../sessions/openInSessionContext.ts | 19 +- .../lib/components/sessions/previewPaths.ts | 9 + .../lib/components/sessions/previewRouter.ts | 16 +- .../sessions/sessionDeployModel.svelte.ts | 35 +- .../sessions/sessionDeployModel.test.ts | 9 + .../components/sessions/sessionDeployModel.ts | 4 +- .../components/sessions/sessionMode.svelte.ts | 4 + .../components/sessions/sessionMode.test.ts | 8 +- .../sessions/sessionPreviewTabs.svelte.ts | 21 +- .../sessions/sessionPreviewTabs.test.ts | 23 +- .../sessions/sessionRuntime.svelte.ts | 121 +- .../sessions/sessionSwitch.svelte.ts | 36 +- .../components/sessions/sessionSwitch.test.ts | 17 +- .../triggers/TriggerEditorToolbar.svelte | 6 +- .../triggers/amqp/AmqpTriggerEditor.svelte | 15 + .../amqp/AmqpTriggerEditorInner.svelte | 24 +- .../triggers/email/EmailTriggerEditor.svelte | 15 + .../email/EmailTriggerEditorInner.svelte | 24 +- .../triggers/gcp/GcpTriggerEditor.svelte | 15 + .../triggers/gcp/GcpTriggerEditorInner.svelte | 24 +- .../triggers/kafka/KafkaTriggerEditor.svelte | 15 + .../kafka/KafkaTriggerEditorInner.svelte | 24 +- .../triggers/mqtt/MqttTriggerEditor.svelte | 15 + .../mqtt/MqttTriggerEditorInner.svelte | 24 +- .../triggers/nats/NatsTriggerEditor.svelte | 15 + .../nats/NatsTriggerEditorInner.svelte | 24 +- .../postgres/PostgresTriggerEditor.svelte | 15 + .../PostgresTriggerEditorInner.svelte | 24 +- .../schedules/ScheduleAdvancedOptions.svelte | 670 +++++++ .../triggers/schedules/ScheduleEditor.svelte | 12 + .../schedules/ScheduleEditorInner.svelte | 662 +------ .../triggers/sqs/SqsTriggerEditor.svelte | 15 + .../triggers/sqs/SqsTriggerEditorInner.svelte | 24 +- frontend/src/lib/pipelinePaths.ts | 5 + frontend/src/lib/utils/describeCron.test.ts | 37 + frontend/src/lib/utils/describeCron.ts | 128 ++ frontend/src/lib/utils_workspace_deploy.ts | 3 +- .../(root)/(logged)/pipeline/+page.svelte | 32 +- .../(logged)/pipeline/[folder]/+page.svelte | 1583 +++++++++++------ system_prompts/auto-generated/prompts.ts | 42 +- .../skills/write-pipeline/SKILL.md | 42 +- system_prompts/base/pipeline-base.md | 42 +- 137 files changed, 11099 insertions(+), 2864 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/NodeActionsMenu.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/NodeFixButton.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/NodeOnDot.svelte delete mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineDbtSignpost.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineDeployErrors.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineDeployTriggersModal.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/RunStateChip.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetsOnlyFolders.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineAssetPrefetch.svelte.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDataUploads.svelte.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDelete.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.svelte.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDrafts.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts create mode 100644 frontend/src/lib/components/common/contextmenu/fromMenuItems.ts create mode 100644 frontend/src/lib/components/common/table/PipelineRow.svelte create mode 100644 frontend/src/lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte create mode 100644 frontend/src/lib/utils/describeCron.test.ts create mode 100644 frontend/src/lib/utils/describeCron.ts diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index e5b3619a27..eb5d46ef57 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -7915,10 +7915,48 @@ A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates step A pipeline node that produces a table should almost always be a **\`duckdb\`** node that materializes its output into a **DuckLake** table with \`-- materialize ducklake:///\` (in a DuckDB node the annotation uses SQL \`--\` comment syntax; write the body as a bare \`SELECT\` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else: - \`postgresql\` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live). -- \`bun\` / \`python3\` — only for non-tabular work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node still produces tabular data for downstream steps, land it in DuckLake (write it with the wmill SDK / ducklake helpers) rather than inventing a parallel store. +- \`bun\` / \`python3\` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — rather than inventing a parallel store. Do not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal. +## Ingesting from Python / TypeScript + +A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with \`wmill.ducklake()\`, or into a data table with \`wmill.datatable()\`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table. + +Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema: + +\`\`\`python +# pipeline +# on schedule +import wmill + +def main(): + rows = fetch_charges() # list[dict] + lake = wmill.ducklake() + lake.query(""" + CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true) + """, rows=rows).fetch() +\`\`\` + +\`\`\`ts +// pipeline +// on schedule +import * as wmill from "windmill-client" + +export async function main() { + const rows = await fetchCharges() + const lake = wmill.ducklake() + await lake\`CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json(\${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)\`.fetch() +} +\`\`\` + +- Use \`INSERT INTO
SELECT ...\` instead of \`CREATE OR REPLACE\` to append (an incremental pull), with a one-time \`CREATE TABLE IF NOT EXISTS\` first. +- Assign the client to a variable (\`lake = wmill.ducklake()\`) and keep the SQL a **string literal** (Python) or a template without \`sql.raw\` (TS): that is what lets the graph record the node's write to \`ducklake://main/
\`. A chained \`wmill.ducklake().query(...)\` or SQL built in a variable records no output, so the downstream edge never forms. +- \`wmill.ducklake("")\` targets a non-default catalog; \`wmill.datatable("")\` writes a Postgres data table the same way (Python \`db.query(sql, *args)\` with \`$1\` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above). +- The downstream node triggers on the table itself: \`-- on ducklake://main/stripe_charges\`. + ## Storage prerequisites A DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and \`s3://\` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build. @@ -7962,7 +8000,7 @@ The key must be a **string literal** — the graph parser is static and cannot f ## Materialize (the managed output) -> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake:///
\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the output via the SDK (\`wmill.writeS3File(...)\`, ducklake helpers, …) and let it be inferred. Use \`duckdb\` when a node should materialize a DuckLake table. +> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake:///
\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the table itself with \`wmill.ducklake()\` (see "Ingesting from Python / TypeScript") and let the output be inferred. Use \`duckdb\` when a node should materialize a DuckLake table. > > The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: \`// materialize manual dbt:////\`, where \`\` is a warehouse the workspace configures under Settings → dbt. \`manual\` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql \`CREATE TABLE\` / \`INSERT\`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a \`source\`: the declared relation and the dbt model land on ONE graph node, and a downstream \`// on dbt:////\` fires when the ingestion node completes. diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 76dc030c58..1e318ed9b2 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -142,7 +142,13 @@ let hintTimer: ReturnType | undefined const kindLabel = $derived( - itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script' + itemKind === 'flow' + ? 'flow' + : itemKind === 'app' || itemKind === 'raw_app' + ? 'app' + : itemKind === 'data_pipeline' + ? 'pipeline' + : 'script' ) // Each false → true transition fires a hint (others > loaded). Seeded to diff --git a/frontend/src/lib/components/CronBuilder.svelte b/frontend/src/lib/components/CronBuilder.svelte index bcf8508c12..3bceabb417 100644 --- a/frontend/src/lib/components/CronBuilder.svelte +++ b/frontend/src/lib/components/CronBuilder.svelte @@ -15,7 +15,7 @@ contentClasses="p-4" > {#snippet trigger()} - {/snippet} diff --git a/frontend/src/lib/components/DatatablePicker.svelte b/frontend/src/lib/components/DatatablePicker.svelte index 53617acf36..17ad6f3361 100644 --- a/frontend/src/lib/components/DatatablePicker.svelte +++ b/frontend/src/lib/components/DatatablePicker.svelte @@ -17,6 +17,9 @@ selectInputClass?: string class?: string onClear?: () => void + /** Size the input to its content, like Path's folder picker. */ + useContentEditable?: boolean + RightIcon?: any } let { @@ -27,7 +30,9 @@ placeholder = undefined, selectInputClass = '', class: className = '', - onClear = undefined + onClear = undefined, + useContentEditable = false, + RightIcon = undefined }: Props = $props() let datatables = usePromise(() => @@ -47,6 +52,8 @@ {placeholder} inputClass={selectInputClass} {onClear} + {useContentEditable} + {RightIcon} /> {#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'datatable', path: value })} diff --git a/frontend/src/lib/components/DucklakePicker.svelte b/frontend/src/lib/components/DucklakePicker.svelte index 3a6f2bf739..d6ed4d0cdf 100644 --- a/frontend/src/lib/components/DucklakePicker.svelte +++ b/frontend/src/lib/components/DucklakePicker.svelte @@ -17,6 +17,9 @@ selectInputClass?: string class?: string onClear?: () => void + /** Size the input to its content, like Path's folder picker. */ + useContentEditable?: boolean + RightIcon?: any } let { @@ -27,7 +30,9 @@ placeholder = undefined, selectInputClass = '', class: className = '', - onClear = undefined + onClear = undefined, + useContentEditable = false, + RightIcon = undefined }: Props = $props() let ducklakes = usePromise(() => @@ -45,6 +50,8 @@ {placeholder} inputClass={selectInputClass} {onClear} + {useContentEditable} + {RightIcon} /> {#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'ducklake', path: value })} diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index f9df8adcef..03bf5ce87e 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -1523,6 +1523,9 @@ } catch (e) { console.log('error initializing services', e) } + // Unmounted while the services loaded: the container is detached, and + // creating the editor on it throws. + if (destroyed || !divEl?.isConnected) return // vscode.languages.registerDefinitionProvider('*', { // provideDefinition(document, position, token) { diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 8990f70ae5..8e60d40f72 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -2101,43 +2101,15 @@ {/if} {#if customUi?.previewPanel?.hideArgs} - -
- {#if testJob?.id && testJob.type === 'CompletedJob' && opWs} - - - {/if} -
+ +
+ +
+
{#if testIsLoading} {@render cancelTestButton('sm', 'shadow-md')} {:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 || customUi?.previewPanel?.onBoundedRun} @@ -2271,6 +2243,31 @@ {@render runTestButton('sm', 'shadow-md')} {/if}
+ {#if testJob?.id && testJob.type === 'CompletedJob' && opWs} + + + {/if} +
{#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0}
string | undefined /** Header variant: no path line (the host shows it) and a lighter summary. */ compact?: boolean /** Rendered inside the trigger in place of the summary, for a host that hangs a second copy @@ -51,6 +58,7 @@ editable = false, onSaved, kind = 'flow', + saveOverride = undefined, compact = false, label, triggerClass = 'block min-w-0 max-w-full px-1 py-0.5 rounded text-left cursor-pointer hover:bg-surface-hover transition-colors', @@ -67,7 +75,9 @@ let popoverOpen = $state(false) // Resolved before the popover opens, not on opening it: the path field only exists for an owner, // and `openFocus` looks for it in the same flush the popover renders in. - const own = $derived(isOwner(path ?? '', actingUser, $operatingWorkspace)) + const own = $derived( + saveOverride !== undefined || isOwner(path ?? '', actingUser, $operatingWorkspace) + ) let onBehalfOfEmail = $state(undefined) let summaryInput: ReturnType | undefined = $state() let labelsDirty = $state(false) @@ -101,6 +111,17 @@ async function save(close: () => void) { const initialPath = path ?? '' const newPath = own ? editPath : initialPath + if (saveOverride) { + const error = saveOverride({ path: newPath, summary: editSummary, labels }) + if (error) { + sendUserToast(error, true) + return + } + labelsDirty = false + close() + onSaved?.(newPath) + return + } try { await updateItemPathAndSummary({ diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte new file mode 100644 index 0000000000..49a222148b --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte @@ -0,0 +1,150 @@ + + + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte new file mode 100644 index 0000000000..2a54fe11d4 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte @@ -0,0 +1,73 @@ + + + + + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte new file mode 100644 index 0000000000..8ebd24455c --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte @@ -0,0 +1,23 @@ + + + + + + + Add downstream step + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte index 880abb685a..57a31e34d8 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte @@ -1,145 +1,36 @@ - - {#snippet trigger()} - - - {/snippet} - + +
+ + {#snippet trigger()} + + + {/snippet} + +
diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 27f6ce6be7..87b085593c 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -1,8 +1,11 @@ -
+
onNodePointerEnter(node.id)} + onnodepointerleave={onNodePointerLeave} + onedgepointerenter={onEdgeEnter} + onedgepointerleave={onEdgeLeave} --background-color={false} >
- + + {#if assetsOnlyToggle} + + { + assetsOnly = !e.detail + if (assetsOnlyFolder != undefined) setAssetsOnlyFolder(assetsOnlyFolder, assetsOnly) + }} + /> + {#if withoutPassive.dropped > 0} + { + showPassiveReads = e.detail + try { + localStorage.setItem(SHOW_PASSIVE_READS_KEY, String(e.detail)) + } catch {} + }} + /> + {/if} + + {/if} {#if showMinimap} + +{/if} + +{#if scheduleWizard} + +{/if} + +{#if onAddPipelineScript} + +{/if} + +` + +export const BUILDER_FRAMEWORK_TEMPLATES = { + react19: { ...react19Template, '/App.tsx': builderAppTsx }, + react18: { ...react18Template, '/App.tsx': builderAppTsx }, + svelte5: { ...svelte5Template, '/App.svelte': builderAppSvelte } +} + type StarterArg = { type: 'string' | 'number' } function inlineRunnable( diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index de05359bd5..05b8ea6a2f 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -16,7 +16,11 @@ import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' import { removeSession, type SessionRuntime } from './sessionRuntime.svelte' - import { FRAMEWORK_TEMPLATES, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates' + import { + BUILDER_FRAMEWORK_TEMPLATES, + FRAMEWORK_TEMPLATES, + STARTER_RUNNABLES + } from '$lib/components/raw_apps/templates' import SessionEditorTarget from './SessionEditorTarget.svelte' import { runResetToDeployed } from '$lib/userDraftToast' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' @@ -63,7 +67,10 @@ const files = cell.store.val?.files if (!newRawApp || (cell.saved.val && !cell.saved.val.no_deployed) || !files) return false const count = Object.keys(files).length - return Object.values(FRAMEWORK_TEMPLATES).some((template: Record) => { + return [ + ...Object.values(FRAMEWORK_TEMPLATES), + ...Object.values(BUILDER_FRAMEWORK_TEMPLATES) + ].some((template: Record) => { const keys = Object.keys(template) return count === keys.length && keys.every((k) => files[k] === template[k]) }) diff --git a/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte b/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte index da7cd5a3de..b9ad80f63e 100644 --- a/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte @@ -31,6 +31,7 @@ // Kept out of `operatorWorkspaceSettings` so the visibility table's "Enable all" never flips a // write right, and so these rows stay out of that table. let builderFlows = $state(false) + let builderApps = $state(false) // Withdrawable rather than granted: operators hold these until an admin turns them off. let manageSchedules = $state(true) let manageTriggers = $state(true) @@ -38,6 +39,7 @@ let originalSettings = $state({ ...untrack(() => operatorWorkspaceSettings), builder_flows: false, + builder_apps: false, manage_schedules: true, manage_triggers: true }) @@ -51,15 +53,24 @@ const settingsPayload = $derived({ ...operatorWorkspaceSettings, builder_flows: builderFlows, + builder_apps: builderApps, manage_schedules: manageSchedules, manage_triggers: manageTriggers }) - // The seat cost lands when the right is first granted, so confirm only on that transition. - const grantsBuilderRight = $derived(builderFlows && !originalSettings.builder_flows) + // The seat cost lands on the first right granted, so confirm only when going from neither to + // either. Adding the second one later is already paid for. + const grantsFirstBuilderRight = $derived( + (builderFlows || builderApps) && + !(originalSettings.builder_flows || originalSettings.builder_apps) + ) + + const grantedBuilderKinds = $derived( + [builderFlows && 'flows', builderApps && 'full-code apps'].filter(Boolean).join(' and ') + ) function onSaveClicked() { - if (grantsBuilderRight) { + if (grantsFirstBuilderRight) { confirmBuilderOpen = true } else { saveSettings() @@ -105,17 +116,20 @@ // would be saved here with the next unrelated change, builder rights among them. const { builder_flows: remoteFlows, + builder_apps: remoteApps, manage_schedules: remoteSchedules, manage_triggers: remoteTriggers, ...remoteVisibility } = settings.operator_settings ?? {} operatorWorkspaceSettings = { ...defaultVisibility, ...remoteVisibility } builderFlows = remoteFlows ?? false + builderApps = remoteApps ?? false manageSchedules = remoteSchedules ?? true manageTriggers = remoteTriggers ?? true originalSettings = { ...operatorWorkspaceSettings, builder_flows: builderFlows, + builder_apps: builderApps, manage_schedules: manageSchedules, manage_triggers: manageTriggers } @@ -155,8 +169,8 @@
@@ -165,6 +179,11 @@ options={{ right: 'Operators can build flows' }} size="xs" /> +
- They can create, edit and delete flows wherever their folder permissions already let them - write. Review those permissions before enabling. + They can create, edit and delete {grantedBuilderKinds} wherever their folder permissions already + let them write. Review those permissions before enabling.
diff --git a/frontend/src/lib/editRights.test.ts b/frontend/src/lib/editRights.test.ts index a8e9b7eafa..faa853e708 100644 --- a/frontend/src/lib/editRights.test.ts +++ b/frontend/src/lib/editRights.test.ts @@ -26,6 +26,8 @@ describe('edit rights', () => { expect(roleCanAuthor('flow', user({}), builder)).toBe(true) expect(roleCanAuthor('script', user({}), builder)).toBe(false) expect(roleCanAuthor('flow', user({}), undefined)).toBe(false) + expect(roleCanDraft('raw_app', user({}), builder)).toBe(false) + expect(roleCanDraft('raw_app', user({}), { builder_apps: true })).toBe(true) }) // drafts.rs admits an admin before its operator branch; the item handlers do not. diff --git a/frontend/src/lib/editRights.ts b/frontend/src/lib/editRights.ts index 3034840a6e..9073a03d28 100644 --- a/frontend/src/lib/editRights.ts +++ b/frontend/src/lib/editRights.ts @@ -26,7 +26,10 @@ export type EditKind = | (string & {}) type OperatorRights = - | Pick, 'builder_flows' | 'manage_schedules' | 'manage_triggers'> + | Pick< + NonNullable, + 'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers' + > | null | undefined @@ -35,8 +38,9 @@ type OperatorRights = * in a workspace with these `operator_settings`: the role half of every check against the item * handlers. Saving a draft is `roleCanDraft`'s rule, and which items they may write is * `canEditItem`'s other half. For an operator: - * - code (scripts, apps, agents, pipelines): never; - * - flows: where the workspace grants `builder_flows` (`check_operator_can_build_flows`); + * - code (scripts, low-code apps, agents, pipelines): never; + * - flows and full-code apps: where the workspace grants `builder_flows` / `builder_apps` + * (`check_operator_can_build`); * - schedules and triggers: unless the workspace set `manage_*` to false — a withdrawable * right, so an unset key grants it (docs/operator-write-rights.md); * - resources, variables and groups: always, their permissions alone decide. @@ -49,7 +53,8 @@ export function roleCanAuthor( if (!user?.operator) return true switch (kind) { case 'flow': - return operatorSettings?.builder_flows === true + case 'raw_app': + return builderGrants(kind, operatorSettings) case 'schedule': return operatorSettings?.manage_schedules !== false case 'trigger': @@ -66,8 +71,8 @@ export function roleCanAuthor( /** * Whether the user may save drafts of `kind` — what every session editor writes. Mirrors drafts.rs * `require_can_write_path`, which differs from the item handlers both ways: it admits an admin - * before its operator branch, and it refuses every operator draft but a flow under - * `builder_flows` — schedules, triggers, resources and variables included, though an operator may + * before its operator branch, and it refuses every operator draft but a flow or full-code app + * under its builder right — schedules, triggers, resources and variables included, though an operator may * write those directly. */ export function roleCanDraft( @@ -76,7 +81,14 @@ export function roleCanDraft( operatorSettings: OperatorRights ): boolean { if (user?.is_admin || user?.is_super_admin || !user?.operator) return true - return kind === 'flow' && operatorSettings?.builder_flows === true + return builderGrants(kind, operatorSettings) +} + +function builderGrants(kind: EditKind, operatorSettings: OperatorRights): boolean { + return ( + (kind === 'flow' && operatorSettings?.builder_flows === true) || + (kind === 'raw_app' && operatorSettings?.builder_apps === true) + ) } /** `roleCanDraft` for the item at `path`, with its own permissions as in `canEditItem`. */ diff --git a/frontend/src/lib/operatorWriteRights.ts b/frontend/src/lib/operatorWriteRights.ts index 6b6aac510a..916e39dce6 100644 --- a/frontend/src/lib/operatorWriteRights.ts +++ b/frontend/src/lib/operatorWriteRights.ts @@ -1,5 +1,5 @@ -import { derived, fromStore, type Readable } from 'svelte/store' -import { userWorkspaces, workspaceStore } from '$lib/stores' +import { derived, fromStore, get, readable, type Readable } from 'svelte/store' +import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { useActingUser } from '$lib/actingUser.svelte' import { canDraftItem, canEditItem, roleCanAuthor, roleCanDraft } from '$lib/editRights' @@ -54,22 +54,57 @@ export const useTriggerLock = () => writeLock('trigger', 'triggers') /** * True when the user is an operator of `workspace` and it granted operators the right to compose - * flows out of runnables that are already deployed. They still author no code, and everywhere else - * `operator` keeps meaning read-only, so a gate on the operator role has to consult this before + * this kind out of runnables that are already deployed. They still author no code, and everywhere + * else `operator` keeps meaning read-only, so a gate on the operator role has to consult this before * refusing. `operator_settings` is null for a non-operator, so this is false for them. + * + * The two rights are granted independently: anything that authors one kind must gate on that + * kind, or a flows-only workspace offers app affordances the backend then refuses. */ -function builderFlows(workspace: Readable): Readable { +function builderRight( + key: 'builder_flows' | 'builder_apps', + workspace: Readable +): Readable { return derived( [userWorkspaces, workspace], ([$userWorkspaces, $workspace]) => - $userWorkspaces.find((w) => w.id === $workspace)?.operator_settings?.builder_flows === true + $userWorkspaces.find((w) => w.id === $workspace)?.operator_settings?.[key] === true + ) +} + +/** Either right, only for surfaces that are not per-kind, such as the create menu's visibility. */ +function anyBuilderRight(workspace: Readable): Readable { + return derived( + [builderRight('builder_flows', workspace), builderRight('builder_apps', workspace)], + ([flows, apps]) => flows || apps ) } /** Builder rights in the operating workspace. Reads context: call during component * initialisation. */ -export const useOperatorBuilderFlows = () => builderFlows(useOperatingWorkspace()) +export const useOperatorBuilderFlows = () => builderRight('builder_flows', useOperatingWorkspace()) +/** Reads context: call during component initialisation. */ +export const useOperatorBuilderApps = () => builderRight('builder_apps', useOperatingWorkspace()) +/** Reads context: call during component initialisation. */ +export const useOperatorBuilderRights = () => anyBuilderRight(useOperatingWorkspace()) /** Builder rights in the navigation workspace, for code outside any component: the legacy AI * chat is one instance for the whole app and acts on the workspace the nav is on. */ -export const navigationOperatorBuilderFlows = builderFlows(workspaceStore) +export const navigationOperatorBuilderFlows = builderRight('builder_flows', workspaceStore) +export const navigationOperatorBuilderApps = builderRight('builder_apps', workspaceStore) +export const navigationOperatorBuilderRights = anyBuilderRight(workspaceStore) + +/** The app builder right in a given workspace, read once: for code acting on an explicit + * workspace rather than the nav's, such as the global AI chat. */ +export const operatorBuilderAppsIn = (workspace: string | undefined) => + get(builderRight('builder_apps', readable(workspace))) + +/** Resolves once the workspace list has loaded. Every builder right reads false until then, so + * code that picks what a builder starts with on a fresh page load has to wait for it. */ +export async function workspaceListLoaded(): Promise { + let unsubscribe = () => {} + await new Promise((resolve) => { + unsubscribe = usersWorkspaceStore.subscribe((list) => list && resolve()) + }) + unsubscribe() +} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 9ed2ca296a..ee2bb8fdf7 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -4,6 +4,8 @@ import { AppService } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' + import { useOperatorBuilderApps, workspaceListLoaded } from '$lib/operatorWriteRights' + import { conformBuilderAppPolicy } from '$lib/components/raw_apps/builderAppPolicy' import { readFieldsRecursively } from '$lib/utils' import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' @@ -32,7 +34,11 @@ type RawAppBuildMode, type RawAppTemplatePickerResult } from '$lib/components/raw_apps/RawAppTemplatePicker.svelte' - import { react19Template, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates' + import { + BUILDER_FRAMEWORK_TEMPLATES, + react19Template, + STARTER_RUNNABLES + } from '$lib/components/raw_apps/templates' import { aiChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' type RawAppDraft = { @@ -97,6 +103,8 @@ // edits to the just-deployed app autosaved to a dead key (autosave appeared // broken). See /scripts/edit, which derives its draft path the same way. // effectivePath omitted: the live-editor-draft entry is owned by RawAppEditor. + const operatorBuilderApps = useOperatorBuilderApps() + const draftSync = usePageDraftSync({ itemKind: 'raw_app', path: () => page.params.path ?? '', @@ -188,6 +196,8 @@ // `?new_draft` loads the saved draft instead of blanking it — see // shouldSeedNewDraft. if (shouldSeedNewDraft(page.url.searchParams, $workspaceStore, 'raw_app', path)) { + await workspaceListLoaded() + if (tok !== loadAppToken) return isNewApp = true // Page reused across same-route nav: clear the previous path's // draft-presence state so it doesn't bleed onto the fresh draft. @@ -270,8 +280,10 @@ } // Seed the React 19 template so the editor has a usable state even if the // user dismisses the picker without selecting. - const seedFiles = { ...react19Template } - const seedRunnables = structuredClone(STARTER_RUNNABLES) + const seedFiles = { + ...($operatorBuilderApps ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) + } + const seedRunnables = starterRunnables() savedApp = { summary: '', value: { files: seedFiles as any, runnables: seedRunnables as any }, @@ -520,10 +532,22 @@ let rawAppEditor: RawAppEditor | undefined = $state() + // The starter runnables are inline scripts, which the backend refuses from an operator with + // builder rights: seeding them would make their very first deploy fail. + function starterRunnables() { + return $operatorBuilderApps ? {} : structuredClone(STARTER_RUNNABLES) + } + + // The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user + // deployed carries, so the editor conforms the policy as soon as it opens. + $effect(() => { + if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore) + }) + async function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) { const picked = { files: { ...result.files }, - runnables: { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) }, + runnables: { ...result.runnables, ...starterRunnables() }, data: result.data, summary: '', policy: result.policy From d3ddef0b7ec4705aa578c4eca24b96e9152485bd Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 9 Oct 2026 20:00:50 +0200 Subject: [PATCH 139/174] feat: retry transient AI provider failures before output reaches readers (#11623) * feat: retry transient AI provider failures before output reaches readers Co-Authored-By: Claude Opus 5.5 * fix: keep completed AI streams, stop retrying prompt blocks, bound retry-after Co-Authored-By: Claude Opus 5.5 * refactor: share the AI send-error retry rule between agent and proxy Co-Authored-By: Claude Opus 5.5 * fix: accept a clean end of stream from OpenAI-compatible providers Co-Authored-By: Claude Opus 5.5 * fix: classify AI provider errors the way the reference clients do Co-Authored-By: Claude Opus 5.5 * fix: count a Gemini function call as the end of its turn Co-Authored-By: Claude Opus 5.5 * fix: count an Anthropic stop_reason as the end of its stream Co-Authored-By: Claude Opus 5.5 * fix: accept a Gemini answer that reports usage and no output Co-Authored-By: Claude Opus 5.5 * fix: accept a Gemini stream that closes cleanly with no output Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- backend/Cargo.lock | 1 + backend/windmill-ai/Cargo.toml | 1 + backend/windmill-ai/src/ai_bedrock.rs | 23 + backend/windmill-ai/src/ai_google.rs | 56 +++ backend/windmill-ai/src/lib.rs | 1 + backend/windmill-ai/src/providers/bedrock.rs | 55 +- .../windmill-ai/src/providers/google_ai.rs | 53 +- backend/windmill-ai/src/providers/other.rs | 1 + backend/windmill-ai/src/retry.rs | 474 ++++++++++++++++++ backend/windmill-ai/src/sse.rs | 244 ++++++++- backend/windmill-api/src/ai.rs | 17 +- .../src/ai/stream_event_processor.rs | 20 +- backend/windmill-worker/src/ai_executor.rs | 58 ++- 13 files changed, 926 insertions(+), 78 deletions(-) create mode 100644 backend/windmill-ai/src/retry.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 758627445d..64a152c962 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14935,6 +14935,7 @@ dependencies = [ "indexmap 2.14.2", "lazy_static", "mime_guess", + "rand 0.9.5", "reqwest 0.13.5", "serde", "serde_json", diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index b1dea3ae54..44c4ac83c7 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -28,6 +28,7 @@ eventsource-stream.workspace = true futures.workspace = true http.workspace = true mime_guess.workspace = true +rand.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index a9318a1f3a..f55fcca415 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -477,6 +477,29 @@ pub async fn assume_role_with_oidc_token( // Error Formatting // ============================================================================ +/// The error for a Bedrock stream that failed after it opened, retrying the exceptions the +/// Vercel AI SDK retries (`amazon-bedrock-stream-error.ts`) and a broken connection. The +/// SDK's own retries cover the opening call alone. +pub fn bedrock_stream_error( + error: &aws_sdk_bedrockruntime::error::SdkError< + aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError, + R, + >, +) -> Error { + let message = format!("Bedrock stream error: {}", format_bedrock_error(error)); + match error.as_service_error() { + Some(e) + if !(e.is_internal_server_exception() + || e.is_model_stream_error_exception() + || e.is_service_unavailable_exception() + || e.is_throttling_exception()) => + { + Error::AIError(message) + } + _ => crate::retry::transient_error(message, None), + } +} + /// Format AWS SDK errors with detailed information pub fn format_bedrock_error(error: &aws_sdk_bedrockruntime::error::SdkError) -> String where diff --git a/backend/windmill-ai/src/ai_google.rs b/backend/windmill-ai/src/ai_google.rs index bb8ae83e13..9f48edca8a 100644 --- a/backend/windmill-ai/src/ai_google.rs +++ b/backend/windmill-ai/src/ai_google.rs @@ -326,6 +326,17 @@ pub struct GeminiSSEEvent { pub candidates: Option>, #[serde(rename = "usageMetadata")] pub usage_metadata: Option, + #[serde(default)] + pub error: Option, + #[serde(rename = "promptFeedback", default)] + pub prompt_feedback: Option, +} + +/// Set when Gemini refuses the prompt itself, in which case no candidate follows. +#[derive(Deserialize, Debug)] +pub struct GeminiPromptFeedback { + #[serde(rename = "blockReason", default)] + pub block_reason: Option, } // ============================================================================ @@ -550,10 +561,40 @@ pub fn openai_tools_to_gemini( } } +/// Follows a Gemini stream to tell a complete turn from one cut short. +#[derive(Default)] +pub struct GeminiTurn { + ended: bool, + has_output: bool, +} + +impl GeminiTurn { + pub fn observe(&mut self, event: &GeminiParsedEvent) { + // A function call arrives whole and may come without a `finishReason`, so it + // counts too, as in gemini-cli's stream validation. + self.ended |= event.finish_reason.is_some() || !event.tool_calls.is_empty(); + self.has_output |= event.text.is_some() || event.reasoning.is_some(); + } + + /// Whether the turn's final event arrived. + pub fn ended(&self) -> bool { + self.ended + } + + /// Whether a stream that closed cleanly here holds the whole turn. Gemini 2.5 closes + /// its stream without a single event when thinking spends the whole + /// `maxOutputTokens`: an empty answer, which a retry would only get again, rather + /// than a cut one. + pub fn complete_at_close(&self) -> bool { + self.ended || !self.has_output + } +} + /// Parse one Gemini SSE data line into a [`GeminiParsedEvent`]. /// /// Returns `Ok(None)` for empty data or unrecognised payloads (e.g. `"[DONE]"`). /// Logs a warning and returns `Ok(None)` on JSON parse errors rather than propagating. +/// An error the stream reports is returned as one. pub fn parse_gemini_sse_event(data: &str) -> Result, Error> { if data.is_empty() || data == "[DONE]" { return Ok(None); @@ -567,6 +608,21 @@ pub fn parse_gemini_sse_event(data: &str) -> Result, E } }; + if let Some(error) = event.error { + return Err(error.into_error("Gemini")); + } + + // `BLOCK_REASON_UNSPECIFIED` is the protobuf enum's placeholder, not a block. + if let Some(reason) = event + .prompt_feedback + .and_then(|f| f.block_reason) + .filter(|r| !matches!(r.as_str(), "" | "BLOCK_REASON_UNSPECIFIED")) + { + return Err(Error::AIError(format!( + "Gemini blocked the prompt: {reason}" + ))); + } + let mut parsed = GeminiParsedEvent { usage: event.usage_metadata, ..Default::default() }; let Some(candidates) = event.candidates else { diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index ccc40da432..634614a3cf 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -11,6 +11,7 @@ pub mod model_context; pub mod providers; pub mod proxy; pub mod query_builder; +pub mod retry; pub mod sse; pub mod types; pub mod utils; diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index 836244a666..5aa142a127 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -9,11 +9,12 @@ use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL}; use crate::{ ai_bedrock::{ - bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, - bedrock_stream_event_to_reasoning_delta, bedrock_stream_event_to_text, - bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_delta_with_block_index, - bedrock_stream_event_to_tool_start, bedrock_stream_event_to_tool_start_with_block_index, - build_tool_config, create_inference_config, format_bedrock_error, json_to_document, + bedrock_model_supports_prompt_caching, bedrock_stream_error, + bedrock_stream_event_is_block_stop, bedrock_stream_event_to_reasoning_delta, + bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, + bedrock_stream_event_to_tool_delta_with_block_index, bedrock_stream_event_to_tool_start, + bedrock_stream_event_to_tool_start_with_block_index, build_tool_config, + create_inference_config, format_bedrock_error, json_to_document, openai_messages_to_bedrock, streaming_tool_calls_to_openai, BearerTokenProvider, BedrockClient, StreamingToolCall, }, @@ -24,6 +25,7 @@ use crate::{ image_handler::prepare_messages_for_api, proxy::ProxyBuildArgs, query_builder::{ParsedResponse, StreamEventSink}, + retry::{openai_stream_error_chunk, truncated_stream_error}, types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef}, }; use bytes::Bytes; @@ -461,26 +463,31 @@ pub fn sdk_stream_to_sse( async_stream::stream! { let mut stream = stream; let mut state = BedrockSseStreamState::new(id, model, created); + let mut message_stopped = false; - loop { + let failure = loop { match stream.recv().await { Ok(Some(event)) => { + if matches!( + event, + aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(_) + ) { + message_stopped = true; + } for chunk in bedrock_sse_chunks_for_event(&event, &mut state) { yield Ok(chunk); } } - Ok(None) => break, - Err(e) => { - yield Err(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )); - break; - } + Ok(None) => break (!message_stopped).then(truncated_stream_error), + Err(_) if message_stopped => break None, + Err(e) => break Some(bedrock_stream_error(&e)), } - } + }; - yield Ok(Bytes::from("data: [DONE]\n\n")); + match failure { + Some(error) => yield Ok(Bytes::from(openai_stream_error_chunk(&error))), + None => yield Ok(Bytes::from("data: [DONE]\n\n")), + } } } @@ -1081,11 +1088,19 @@ impl BedrockQueryBuilder { // Claude reasoning block for the turn (only populated when thinking is on), // attached to the first tool call for replay before toolUse. let mut reasoning: Option = None; + let mut message_stopped = false; // Process stream events using shared parsing functions loop { match stream.recv().await { Ok(Some(event)) => { + if matches!( + event, + aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(_) + ) { + message_stopped = true; + } + // Fold reasoning deltas into the turn's reasoning block (for // replay before toolUse, required by Claude when thinking is // on) and stream the readable summary as a thinking affordance. @@ -1161,11 +1176,13 @@ impl BedrockQueryBuilder { } } Ok(None) => break, - Err(e) => { - return Err(Error::internal_err(format!("Bedrock stream error: {}", e))); - } + Err(_) if message_stopped => break, + Err(e) => return Err(bedrock_stream_error(&e)), } } + if !message_stopped { + return Err(truncated_stream_error()); + } // Send tool call events to stream processor if let Some(processor) = stream_event_sink.as_ref() { diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 97d0383ba4..bbf45e5e57 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -5,17 +5,20 @@ use crate::{ parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google, GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent, GeminiTextRequest, - GeminiThinkingConfig, GeminiTool, + GeminiThinkingConfig, GeminiTool, GeminiTurn, }, image_handler::{download_and_encode_s3_image, prepare_messages_for_api}, proxy::{ProxyBuildArgs, ProxyRequest}, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, + retry::{ + openai_stream_error_chunk, send_with_retries, transient_error, truncated_stream_error, + }, sse::{GeminiSSEParser, SSEParser}, types::*, }; use async_trait::async_trait; use bytes::Bytes; -use eventsource_stream::Eventsource; +use eventsource_stream::{EventStreamError, Eventsource}; use futures::{stream::BoxStream, StreamExt}; use http::{header, HeaderMap, HeaderValue, Method, StatusCode}; use serde::Deserialize; @@ -336,9 +339,7 @@ pub async fn handle_google_ai_chat_proxy( ) -> Result { let GoogleAIProxyRequest { request, model, stream } = build_google_ai_chat_proxy_request(args)?; - let response = - send_google_ai_proxy_request(client, request, "Failed to send request to Gemini API") - .await?; + let response = send_google_ai_proxy_request(client, request).await?; if stream { Ok(convert_streaming_response(response, &model)) @@ -356,8 +357,7 @@ pub async fn handle_google_ai_models_proxy( args: &ProxyBuildArgs<'_>, ) -> Result { let request = build_google_ai_models_proxy_request(args); - let response = - send_google_ai_proxy_request(client, request, "Failed to fetch Gemini models").await?; + let response = send_google_ai_proxy_request(client, request).await?; let gemini_resp: GeminiModelsResponse = response.json().await.map_err(|e| { Error::internal_err(format!("Failed to parse Gemini models response: {}", e)) @@ -505,26 +505,13 @@ fn add_google_ai_auth_header(headers: &mut Vec<(String, String)>, api_key: &str, async fn send_google_ai_proxy_request( client: &reqwest::Client, proxy_request: ProxyRequest, - send_error_message: &str, ) -> Result { let mut request = client.request(proxy_request.method.clone(), &proxy_request.url); for (header_name, header_value) in &proxy_request.headers { request = request.header(header_name.as_str(), header_value.as_str()); } - let response = request - .body(proxy_request.body) - .send() - .await - .map_err(|e| Error::internal_err(format!("{}: {}", send_error_message, e)))?; - - if let Err(e) = response.error_for_status_ref() { - let status = e.status().map(|s| s.to_string()).unwrap_or_default(); - let body = response.text().await.unwrap_or_default(); - return Err(Error::AIError(format!("{}: {}", status, body))); - } - - Ok(response) + send_with_retries(request.body(proxy_request.body)).await } fn convert_streaming_response(response: reqwest::Response, model: &str) -> GoogleAIProxyResponse { @@ -535,10 +522,15 @@ fn convert_streaming_response(response: reqwest::Response, model: &str) -> Googl let openai_sse_stream = async_stream::stream! { tokio::pin!(gemini_sse_stream); let mut tool_call_index: usize = 0; - while let Some(event) = gemini_sse_stream.next().await { + let mut turn = GeminiTurn::default(); + let failure = loop { + let Some(event) = gemini_sse_stream.next().await else { + break (!turn.complete_at_close()).then(truncated_stream_error); + }; match event { Ok(event) => match parse_gemini_sse_event(&event.data) { Ok(Some(parsed)) => { + turn.observe(&parsed); for chunk in gemini_event_to_openai_sse_chunks( &parsed, &id, &model, &mut tool_call_index, ) { @@ -546,12 +538,23 @@ fn convert_streaming_response(response: reqwest::Response, model: &str) -> Googl } } Ok(None) => {} - Err(e) => tracing::error!("Error parsing Gemini SSE event: {}", e), + Err(e) => break Some(e), }, + Err(EventStreamError::Transport(_)) if turn.ended() => break None, + Err(EventStreamError::Transport(e)) => { + break Some(transient_error( + format!("The connection to Gemini broke off mid-response: {e}"), + None, + )); + } Err(e) => tracing::error!("Error reading Gemini SSE stream: {}", e), } - } - yield Ok::(Bytes::from("data: [DONE]\n\n")); + }; + let last = match failure { + Some(error) => openai_stream_error_chunk(&error), + None => "data: [DONE]\n\n".to_string(), + }; + yield Ok::(Bytes::from(last)); } .boxed(); diff --git a/backend/windmill-ai/src/providers/other.rs b/backend/windmill-ai/src/providers/other.rs index 1ea54a7e79..fea8edbdac 100644 --- a/backend/windmill-ai/src/providers/other.rs +++ b/backend/windmill-ai/src/providers/other.rs @@ -266,6 +266,7 @@ impl QueryBuilder for OtherQueryBuilder { mut events_str, stream_event_processor, usage: openai_usage, + .. } = openai_sse_parser; // Process streaming events with error handling diff --git a/backend/windmill-ai/src/retry.rs b/backend/windmill-ai/src/retry.rs new file mode 100644 index 0000000000..9de9d309da --- /dev/null +++ b/backend/windmill-ai/src/retry.rs @@ -0,0 +1,474 @@ +use std::time::Duration; + +use reqwest::{header::HeaderMap, RequestBuilder, Response, StatusCode}; +use windmill_common::error::{to_anyhow, Error}; + +/// How many times a provider call is retried after its first attempt. +pub const MAX_RETRIES: u32 = 3; +const BASE_DELAY: Duration = Duration::from_secs(1); +const MAX_BACKOFF: Duration = Duration::from_secs(10); +/// A provider that asks for a longer wait is out of capacity for longer than anyone should +/// block on (a spent daily quota, say), so its error is returned instead of waited out. +const MAX_RETRY_AFTER: Duration = Duration::from_secs(60); + +/// A provider call that failed for a reason a later attempt may not meet: the provider was +/// overloaded, throttled or unreachable, or its response broke off part way. +#[derive(Debug)] +pub struct TransientAIError { + pub message: String, + /// The wait the provider asked for, when it named one. + pub retry_after: Option, +} + +impl std::fmt::Display for TransientAIError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for TransientAIError {} + +pub fn transient_error(message: impl Into, retry_after: Option) -> Error { + to_anyhow(TransientAIError { message: message.into(), retry_after }).into() +} + +pub fn as_transient(error: &Error) -> Option<&TransientAIError> { + match error { + Error::Anyhow { error, .. } => error.downcast_ref::(), + _ => None, + } +} + +/// The error to report once a transient failure is no longer retried, without the source +/// location `Error::Anyhow` renders. +pub fn into_final_error(error: Error) -> Error { + match as_transient(&error) { + Some(transient) => Error::AIError(transient.message.clone()), + None => error, + } +} + +/// The statuses the OpenAI and Anthropic SDKs and the Vercel AI SDK retry. +fn is_retryable_status(status: u16) -> bool { + matches!(status, 408 | 409 | 429 | 500..=599) +} + +/// The error for a provider's error response. The rules follow the OpenAI and Anthropic +/// SDKs (`x-should-retry`, then the status) and gemini-cli (a daily quota is spent until +/// tomorrow, and the wait Gemini asks for is in its body). +pub fn response_error( + status: StatusCode, + headers: &HeaderMap, + body: &str, + message: String, +) -> Error { + let retryable = match headers.get("x-should-retry").and_then(|v| v.to_str().ok()) { + Some("true") => true, + Some("false") => false, + _ => { + is_retryable_status(status.as_u16()) + // OpenAI answers a spent balance with the 429 it also uses for rate limits. + && !body.contains("insufficient_quota") + && !google_quota_spent_for_the_day(body) + } + }; + if retryable { + transient_error( + message, + retry_after(headers).or_else(|| google_retry_delay(body)), + ) + } else { + Error::AIError(message) + } +} + +/// The `details` of a Google API error body, where Gemini puts its quota and retry hints. +fn google_error_details(body: &str) -> Vec { + serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("error")?.get("details")?.as_array().cloned()) + .unwrap_or_default() +} + +fn google_quota_spent_for_the_day(body: &str) -> bool { + google_error_details(body).iter().any(|detail| { + detail + .get("violations") + .and_then(|v| v.as_array()) + .is_some_and(|violations| { + violations.iter().any(|violation| { + violation + .get("quotaId") + .and_then(|id| id.as_str()) + .is_some_and(|id| id.contains("PerDay") || id.contains("Daily")) + }) + }) + }) +} + +/// The `RetryInfo.retryDelay` of a Google API error, a protobuf duration such as `"53s"`. +fn google_retry_delay(body: &str) -> Option { + google_error_details(body).iter().find_map(|detail| { + let delay = detail.get("retryDelay")?.as_str()?.strip_suffix('s')?; + let seconds = delay + .parse::() + .ok() + .filter(|v| v.is_finite() && *v >= 0.0)?; + Some(Duration::try_from_secs_f64(seconds).unwrap_or(Duration::MAX)) + }) +} + +/// The wait a provider asked for in its `retry-after-ms` or `retry-after` header. An +/// HTTP-date `retry-after` is ignored, which falls back to the computed backoff. +fn retry_after(headers: &HeaderMap) -> Option { + let seconds = |name: &str, scale: f64| { + headers + .get(name)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v >= 0.0) + .map(|v| Duration::try_from_secs_f64(v / scale).unwrap_or(Duration::MAX)) + }; + seconds("retry-after-ms", 1000.0).or_else(|| seconds("retry-after", 1.0)) +} + +/// The error object a provider sends inside a stream: OpenAI-compatible chunks carry +/// `type` and a string or status `code`, Anthropic `type`, Gemini a status `code` and +/// `status`. +#[derive(Debug, Default, serde::Deserialize)] +pub struct StreamErrorBody { + #[serde(default)] + pub message: Option, + #[serde(default, rename = "type")] + pub kind: Option, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub status: Option, +} + +impl StreamErrorBody { + /// The error for a failure an OpenAI-compatible or Gemini stream reported after opening + /// with a 200, classified as the Vercel AI SDK classifies OpenAI stream errors + /// (`openai-stream-error.ts`): the status `code` holds, else the status its code and type + /// name, else a server error. + pub fn into_error(self, provider: &str) -> Error { + let code = self.code_str(); + let discriminator = [code.as_deref(), self.kind.as_deref()] + .into_iter() + .flatten() + .collect::>() + .join(" ") + .to_lowercase(); + let has = |terms: &[&str]| terms.iter().any(|term| discriminator.contains(term)); + let status = match code.as_deref().and_then(|code| code.parse::().ok()) { + Some(status @ 400..=599) => status, + _ if has(&["insufficient_quota", "rate_limit"]) => 429, + _ if has(&["authentication"]) => 401, + _ if has(&["permission"]) => 403, + _ if has(&["not_found"]) => 404, + _ if has(&["invalid", "bad_request", "context_length"]) => 400, + _ if has(&["overload"]) => 503, + _ if has(&["timeout"]) => 504, + _ => 500, + }; + let retryable = !has(&["insufficient_quota"]) && is_retryable_status(status); + self.into_classified_error(provider, retryable) + } + + /// The error for a failure an Anthropic stream reported after opening with a 200. The + /// kinds retried are those the Vercel AI SDK retries, plus `timeout_error`, which + /// Anthropic documents as its 504; any other kind is a fault in the request or account. + pub fn into_anthropic_error(self) -> Error { + let retryable = matches!( + self.kind.as_deref(), + Some("api_error" | "overloaded_error" | "rate_limit_error" | "timeout_error") + ); + self.into_classified_error("Anthropic", retryable) + } + + fn code_str(&self) -> Option { + match self.code.as_ref()? { + serde_json::Value::String(code) => Some(code.clone()), + serde_json::Value::Number(code) => Some(code.to_string()), + _ => None, + } + } + + fn into_classified_error(self, provider: &str, retryable: bool) -> Error { + let kind = [self.kind.clone(), self.code_str(), self.status] + .into_iter() + .flatten() + .collect::>() + .join("/"); + let message = format!( + "{provider} stream error{}: {}", + (!kind.is_empty()) + .then(|| format!(" ({kind})")) + .unwrap_or_default(), + self.message.as_deref().unwrap_or("no details") + ); + if retryable { + transient_error(message, None) + } else { + Error::AIError(message) + } + } +} + +/// The error for a stream that closed before the provider sent the event ending a +/// complete response: cut off by the provider or by a proxy in between. +pub fn truncated_stream_error() -> Error { + transient_error( + "The AI provider's response stream ended before the response was complete", + None, + ) +} + +/// An OpenAI-format stream chunk reporting `error`, which ends a response converted to that +/// format after it started streaming: the chat's OpenAI SDK throws on a chunk carrying +/// `error`, where a bare end of stream would read as a finished answer. +pub fn openai_stream_error_chunk(error: &Error) -> String { + let message = as_transient(error).map_or_else(|| error.to_string(), |t| t.message.clone()); + format!( + "data: {}\n\n", + serde_json::json!({ "error": { "message": message } }) + ) +} + +/// Sends a provider request, retrying a transient failure while nothing of the response +/// has been read: a failed connection or an error status. Once retries are spent the error +/// carries the provider's answer, as a single attempt's would. +pub async fn send_with_retries(request: RequestBuilder) -> Result { + let mut backoff = Backoff::default(); + loop { + // A streamed body cannot be replayed, so such a request is sent once. + let Some(attempt) = request.try_clone() else { + return send_once(request).await.map_err(into_final_error); + }; + let error = match send_once(attempt).await { + Ok(response) => return Ok(response), + Err(error) => error, + }; + let Some(delay) = as_transient(&error).and_then(|t| backoff.next_delay(t)) else { + return Err(into_final_error(error)); + }; + tracing::warn!( + "AI provider call failed ({error}); retrying in {:.1}s ({}/{MAX_RETRIES})", + delay.as_secs_f64(), + backoff.retries() + ); + tokio::time::sleep(delay).await; + } +} + +/// The error for a provider request that failed before any response arrived. The request +/// timeout is the caller's whole budget, so a timed-out call leaves no time for another +/// attempt; a connect timeout has its own, shorter one. +pub fn send_error(e: reqwest::Error, context: &str) -> Error { + let message = format!("{context}: {e}"); + if e.is_builder() || (e.is_timeout() && !e.is_connect()) { + Error::AIError(message) + } else { + transient_error(message, None) + } +} + +async fn send_once(request: RequestBuilder) -> Result { + let response = request + .send() + .await + .map_err(|e| send_error(e, "Failed to reach the AI provider"))?; + let status = response.status(); + if status.is_client_error() || status.is_server_error() { + let headers = response.headers().clone(); + let body = response.text().await.unwrap_or_default(); + return Err(response_error(status, &headers, &body, body.clone())); + } + Ok(response) +} + +/// Counts the retries of one provider call and spaces them out. +#[derive(Debug, Default)] +pub struct Backoff { + retries: u32, +} + +impl Backoff { + /// The wait before retrying a call that failed with `error`, or `None` once it should + /// fail instead. + pub fn next_delay(&mut self, error: &TransientAIError) -> Option { + if self.retries >= MAX_RETRIES { + return None; + } + let delay = match error.retry_after { + Some(wait) if wait > MAX_RETRY_AFTER => return None, + // A zero wait would retry back to back, so it gets the computed backoff. + Some(wait) if !wait.is_zero() => wait, + // Jittered, so callers throttled together do not come back together. + _ => (BASE_DELAY * 2u32.pow(self.retries)) + .min(MAX_BACKOFF) + .mul_f64(0.5 + rand::random::() * 0.5), + }; + self.retries += 1; + Some(delay) + } + + /// Retries granted so far. + pub fn retries(&self) -> u32 { + self.retries + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Error responses as providers send them, each with the retry decision of the client + /// the rule is taken from. + #[test] + fn classifies_error_responses() { + const GEMINI_QUOTA: &str = r#"{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"quotaId":"QUOTA_ID"}]},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"53s"}]}}"#; + let cases: &[(u16, &[(&str, &str)], &str, Option>)] = &[ + ( + 429, + &[], + r#"{"error":{"code":"rate_limit_exceeded"}}"#, + Some(None), + ), + ( + 429, + &[], + r#"{"error":{"type":"insufficient_quota","code":"insufficient_quota"}}"#, + None, + ), + ( + 529, + &[], + r#"{"type":"error","error":{"type":"overloaded_error"}}"#, + Some(None), + ), + (501, &[], "", Some(None)), + (400, &[], "", None), + (401, &[], "", None), + (400, &[("x-should-retry", "true")], "", Some(None)), + (503, &[("x-should-retry", "false")], "", None), + (429, &[("retry-after", "2")], "", Some(Some(2))), + ( + 429, + &[], + &GEMINI_QUOTA.replace("QUOTA_ID", "GenerateRequestsPerMinutePerProjectPerModel"), + Some(Some(53)), + ), + ( + 429, + &[], + &GEMINI_QUOTA.replace( + "QUOTA_ID", + "GenerateRequestsPerDayPerProjectPerModel-FreeTier", + ), + None, + ), + ]; + for (status, headers, body, expected) in cases { + let headers = headers + .iter() + .map(|(k, v)| (k.parse().unwrap(), v.parse().unwrap())) + .collect::(); + let error = response_error( + StatusCode::from_u16(*status).unwrap(), + &headers, + body, + String::new(), + ); + let got = as_transient(&error).map(|t| t.retry_after.map(|d| d.as_secs())); + assert_eq!(&got, expected, "{status} {headers:?} {body}"); + } + } + + /// In-stream error objects as providers send them, after the 200 opened the stream. + #[test] + fn classifies_stream_errors() { + let retried = |json: &str, anthropic: bool| { + let body: StreamErrorBody = serde_json::from_str(json).unwrap(); + let error = if anthropic { + body.into_anthropic_error() + } else { + body.into_error("AI provider") + }; + as_transient(&error).is_some() + }; + for (json, expected) in [ + (r#"{"message":"m","type":"server_error"}"#, true), + ( + r#"{"message":"m","type":"requests","code":"rate_limit_exceeded"}"#, + true, + ), + ( + r#"{"message":"m","type":"insufficient_quota","code":"insufficient_quota"}"#, + false, + ), + ( + r#"{"message":"m","type":"invalid_request_error","code":"context_length_exceeded"}"#, + false, + ), + // LiteLLM's proxy puts the upstream status in `code` as a string. + (r#"{"message":"m","type":"None","code":"400"}"#, false), + (r#"{"message":"m","code":"503"}"#, true), + (r#"{"code":503,"message":"m","status":"UNAVAILABLE"}"#, true), + ( + r#"{"code":400,"message":"m","status":"INVALID_ARGUMENT"}"#, + false, + ), + (r#"{"message":"m"}"#, true), + ] { + assert_eq!(retried(json, false), expected, "{json}"); + } + for (kind, expected) in [ + ("overloaded_error", true), + ("api_error", true), + ("rate_limit_error", true), + ("timeout_error", true), + ("billing_error", false), + ("invalid_request_error", false), + ("some_new_error", false), + ] { + let json = format!(r#"{{"type":"{kind}","message":"m"}}"#); + assert_eq!(retried(&json, true), expected, "{kind}"); + } + } + + #[test] + fn honours_retry_after_up_to_a_bound() { + let mut headers = HeaderMap::new(); + headers.insert("retry-after", "2".parse().unwrap()); + assert_eq!(retry_after(&headers), Some(Duration::from_secs(2))); + headers.insert("retry-after-ms", "1500".parse().unwrap()); + assert_eq!(retry_after(&headers), Some(Duration::from_millis(1500))); + headers.remove("retry-after-ms"); + headers.insert("retry-after", "1e30".parse().unwrap()); + assert_eq!(retry_after(&headers), Some(Duration::MAX)); + + let mut backoff = Backoff::default(); + let asked = |secs| TransientAIError { + message: String::new(), + retry_after: Some(Duration::from_secs(secs)), + }; + assert_eq!(backoff.next_delay(&asked(5)), Some(Duration::from_secs(5))); + assert_eq!(backoff.next_delay(&asked(3600)), None); + } + + #[test] + fn stops_after_max_retries() { + let mut backoff = Backoff::default(); + let error = TransientAIError { message: String::new(), retry_after: None }; + for _ in 0..MAX_RETRIES { + let delay = backoff.next_delay(&error).expect("retry"); + assert!(delay <= MAX_BACKOFF); + } + assert_eq!(backoff.next_delay(&error), None); + } +} diff --git a/backend/windmill-ai/src/sse.rs b/backend/windmill-ai/src/sse.rs index ada50afca5..bdaa75025f 100644 --- a/backend/windmill-ai/src/sse.rs +++ b/backend/windmill-ai/src/sse.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use eventsource_stream::Eventsource; +use eventsource_stream::{EventStreamError, Eventsource}; use indexmap::IndexMap; use reqwest::Response; use serde::Deserialize; @@ -8,12 +8,13 @@ use tokio_stream::StreamExt; use windmill_common::{error::Error, utils::rd_string}; use crate::{ - ai_google::{parse_gemini_sse_event, GeminiUsageMetadata}, + ai_google::{parse_gemini_sse_event, GeminiTurn, GeminiUsageMetadata}, ai_types::UrlCitation, ai_types::{ AnthropicExtraContent, ExtraContent, GoogleExtraContent, OpenAIFunction, OpenAIToolCall, }, query_builder::StreamEventSink, + retry::{transient_error, truncated_stream_error, StreamErrorBody}, types::{StreamingEvent, TokenUsage}, }; @@ -43,6 +44,8 @@ pub struct OpenAIChoiceDelta { #[derive(Deserialize)] pub struct OpenAIChoice { pub delta: Option, + #[serde(default)] + pub finish_reason: Option, } /// Nested prompt token details returned by the Chat Completions API. @@ -87,6 +90,8 @@ pub struct OpenAISSEEvent { pub choices: Option>, #[serde(default)] pub usage: Option, + #[serde(default)] + pub error: Option, } lazy_static::lazy_static! { @@ -99,6 +104,14 @@ lazy_static::lazy_static! { pub trait SSEParser { async fn parse_event_data(&mut self, data: &str) -> Result<(), Error>; + /// Whether the stream delivered the event that ends a complete response. + fn is_complete(&self) -> bool; + + /// Whether a stream that closed cleanly holds a complete response. + fn is_complete_at_close(&self) -> bool { + self.is_complete() + } + async fn parse_events(&mut self, response: Response) -> Result<(), Error> { let mut stream = response.bytes_stream().eventsource(); let mut consecutive_errors = 0; @@ -113,6 +126,18 @@ pub trait SSEParser { self.parse_event_data(&event.data).await?; } + // The body ends right after a transport error, so it cannot be skipped + // like a malformed event. A timeout spent the caller's whole budget. + Err(EventStreamError::Transport(_)) if self.is_complete() => break, + Err(EventStreamError::Transport(e)) => { + let message = + format!("The connection to the AI provider broke off mid-response: {e}"); + return Err(if e.is_timeout() { + Error::AIError(message) + } else { + transient_error(message, None) + }); + } Err(e) => { consecutive_errors += 1; tracing::error!("Failed to parse SSE event: {}", e); @@ -130,6 +155,9 @@ pub trait SSEParser { } } + if !self.is_complete_at_close() { + return Err(truncated_stream_error()); + } Ok(()) } } @@ -145,6 +173,9 @@ pub struct OpenAISSEParser { pub stream_event_processor: Box, /// Token usage from final chunk (when stream_options.include_usage is true) pub usage: Option, + /// Set by `[DONE]` or by a choice's `finish_reason`: OpenAI-compatible gateways do not + /// all send `[DONE]`. + complete: bool, } impl OpenAISSEParser { @@ -156,13 +187,19 @@ impl OpenAISSEParser { events_str: String::new(), stream_event_processor, usage: None, + complete: false, } } } impl SSEParser for OpenAISSEParser { + fn is_complete(&self) -> bool { + self.complete + } + async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> { if data == "[DONE]" { + self.complete = true; return Ok(()); } @@ -173,13 +210,21 @@ impl SSEParser for OpenAISSEParser { .ok(); if let Some(event) = event { + if let Some(error) = event.error { + return Err(error.into_error("AI provider")); + } + // Extract usage from final chunk (when stream_options.include_usage is true) if let Some(usage) = event.usage { self.usage = Some(usage); } if let Some(mut choices) = event.choices.filter(|s| !s.is_empty()) { - if let Some(delta) = choices.remove(0).delta { + let choice = choices.remove(0); + if choice.finish_reason.is_some() { + self.complete = true; + } + if let Some(delta) = choice.delta { if let Some(reasoning) = delta.reasoning_content.filter(|s| !s.is_empty()) { self.accumulated_reasoning.push_str(&reasoning); let event = StreamingEvent::ReasoningTokenDelta { content: reasoning }; @@ -345,6 +390,8 @@ pub enum AnthropicSSEEvent { MessageDelta { #[serde(default)] usage: Option, + #[serde(default)] + delta: Option, }, #[serde(rename = "message_stop")] MessageStop {}, @@ -353,12 +400,18 @@ pub enum AnthropicSSEEvent { #[serde(rename = "error")] Error { #[serde(default)] - message: Option, + error: StreamErrorBody, }, #[serde(other)] Unknown, } +#[derive(Deserialize, Debug)] +pub struct AnthropicMessageDelta { + #[serde(default)] + pub stop_reason: Option, +} + /// Tracks state of a content block during streaming #[derive(Debug)] #[allow(dead_code)] @@ -391,6 +444,9 @@ pub struct AnthropicSSEParser { /// can be replayed before `tool_use` (required by Claude when thinking is on). pending_reasoning: Option, reasoning_attached: bool, + /// Set by `message_stop` or by the `stop_reason` of the `message_delta` sent just + /// before it, so a gateway that drops `message_stop` does not fail a whole answer. + complete: bool, } impl AnthropicSSEParser { @@ -407,11 +463,16 @@ impl AnthropicSSEParser { usage: None, pending_reasoning: None, reasoning_attached: false, + complete: false, } } } impl SSEParser for AnthropicSSEParser { + fn is_complete(&self) -> bool { + self.complete + } + async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> { let event: Option = serde_json::from_str(data) .inspect_err(|e| { @@ -583,9 +644,8 @@ impl SSEParser for AnthropicSSEParser { .await?; } } - AnthropicSSEEvent::Error { message } => { - let error_msg = message.unwrap_or_else(|| "Unknown error".to_string()); - tracing::error!("Anthropic streaming error: {}", error_msg); + AnthropicSSEEvent::Error { error } => { + return Err(error.into_anthropic_error()); } AnthropicSSEEvent::MessageStart { message } => { // The only event carrying the prompt-side counts. `message_delta` @@ -595,7 +655,10 @@ impl SSEParser for AnthropicSSEParser { self.usage = Some(usage); } } - AnthropicSSEEvent::MessageDelta { usage } => { + AnthropicSSEEvent::MessageDelta { usage, delta } => { + if delta.is_some_and(|d| d.stop_reason.is_some()) { + self.complete = true; + } if let Some(u) = usage { match &mut self.usage { // Field by field, so the prompt counts from `message_start` @@ -614,10 +677,9 @@ impl SSEParser for AnthropicSSEParser { } } } + AnthropicSSEEvent::MessageStop {} => self.complete = true, // Ignore other events - AnthropicSSEEvent::MessageStop {} - | AnthropicSSEEvent::Ping {} - | AnthropicSSEEvent::Unknown => {} + AnthropicSSEEvent::Ping {} | AnthropicSSEEvent::Unknown => {} } } @@ -645,6 +707,7 @@ pub struct GeminiSSEParser { pub annotations: Vec, pub used_websearch: bool, pub usage: Option, + turn: GeminiTurn, } impl GeminiSSEParser { @@ -659,15 +722,25 @@ impl GeminiSSEParser { annotations: Vec::new(), used_websearch: false, usage: None, + turn: GeminiTurn::default(), } } } impl SSEParser for GeminiSSEParser { + fn is_complete(&self) -> bool { + self.turn.ended() + } + + fn is_complete_at_close(&self) -> bool { + self.turn.complete_at_close() + } + async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> { let Some(parsed) = parse_gemini_sse_event(data)? else { return Ok(()); }; + self.turn.observe(&parsed); if let Some(reasoning) = parsed.reasoning.filter(|s| !s.is_empty()) { self.accumulated_reasoning.push_str(&reasoning); @@ -797,6 +870,8 @@ impl OpenAIResponsesUsage { pub struct OpenAIResponsesResponse { #[serde(default)] pub usage: Option, + #[serde(default)] + pub error: Option, } /// SSE event types for OpenAI Responses API streaming @@ -833,6 +908,20 @@ pub enum OpenAIResponsesSSEEvent { #[serde(rename = "response.incomplete")] Incomplete { response: OpenAIResponsesResponse }, + #[serde(rename = "response.failed")] + Failed { response: OpenAIResponsesResponse }, + + /// Flat (`code`, `message`) from OpenAI, nested under `error` from some compatible servers. + #[serde(rename = "error")] + Error { + #[serde(default)] + code: Option, + #[serde(default)] + message: Option, + #[serde(default)] + error: Option, + }, + /// Response created #[serde(rename = "response.created")] Created {}, @@ -890,6 +979,7 @@ pub struct OpenAIResponsesSSEParser { pub usage: Option, /// Reasoning summary parts seen so far, to separate them as paragraphs reasoning_summary_parts: usize, + response_ended: bool, } impl OpenAIResponsesSSEParser { @@ -906,11 +996,16 @@ impl OpenAIResponsesSSEParser { used_websearch: false, usage: None, reasoning_summary_parts: 0, + response_ended: false, } } } impl SSEParser for OpenAIResponsesSSEParser { + fn is_complete(&self) -> bool { + self.response_ended + } + async fn parse_event_data(&mut self, data: &str) -> Result<(), Error> { let event: Option = serde_json::from_str(data) .inspect_err(|e| { @@ -1010,11 +1105,22 @@ impl SSEParser for OpenAIResponsesSSEParser { OpenAIResponsesSSEEvent::Completed { response } | OpenAIResponsesSSEEvent::Incomplete { response } => { + self.response_ended = true; if let Some(usage) = response.usage { self.usage = Some(usage); } } + OpenAIResponsesSSEEvent::Failed { response } => { + return Err(response.error.unwrap_or_default().into_error("OpenAI")); + } + + OpenAIResponsesSSEEvent::Error { code, message, error } => { + return Err(error + .unwrap_or(StreamErrorBody { code, message, ..Default::default() }) + .into_error("OpenAI")); + } + OpenAIResponsesSSEEvent::ReasoningSummaryPartAdded {} => { self.reasoning_summary_parts += 1; if self.reasoning_summary_parts > 1 { @@ -1037,9 +1143,10 @@ impl SSEParser for OpenAIResponsesSSEParser { } } + OpenAIResponsesSSEEvent::Done {} => self.response_ended = true, + // Ignore other event types - OpenAIResponsesSSEEvent::Done {} - | OpenAIResponsesSSEEvent::Created {} + OpenAIResponsesSSEEvent::Created {} | OpenAIResponsesSSEEvent::InProgress {} | OpenAIResponsesSSEEvent::OutputItemDone {} | OpenAIResponsesSSEEvent::ContentPartAdded {} @@ -1093,6 +1200,117 @@ mod tests { } } + fn sse_response(chunks: Vec>) -> Response { + let body = reqwest::Body::wrap_stream(futures::stream::iter(chunks)); + Response::from(http::Response::new(body)) + } + + /// A stream that breaks off, or reports a failure inside its 200, must fail the call + /// rather than hand back what arrived as if it were the whole answer. + #[tokio::test] + async fn stream_failures_are_errors_not_partial_answers() { + use crate::retry::as_transient; + + const STARTED: &str = concat!( + "data: {\"type\":\"message_start\"}\n\n", + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}\n\n", + ); + let anthropic = |chunks| async move { + AnthropicSSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(chunks)) + .await + }; + + let overloaded = anthropic(vec![ + Ok(STARTED), + Ok("data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n"), + ]) + .await + .unwrap_err(); + assert!(as_transient(&overloaded).is_some(), "{overloaded}"); + + let refused = anthropic(vec![ + Ok(STARTED), + Ok("data: {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"bad\"}}\n\n"), + ]) + .await + .unwrap_err(); + assert!(as_transient(&refused).is_none(), "{refused}"); + + let truncated = anthropic(vec![Ok(STARTED)]).await.unwrap_err(); + assert!(as_transient(&truncated).is_some(), "{truncated}"); + + let dropped = anthropic(vec![ + Ok(STARTED), + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "reset", + )), + ]) + .await + .unwrap_err(); + assert!(as_transient(&dropped).is_some(), "{dropped}"); + + anthropic(vec![ + Ok(STARTED), + Ok("data: {\"type\":\"message_stop\"}\n\n"), + ]) + .await + .unwrap(); + + anthropic(vec![ + Ok(STARTED), + Ok("data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n"), + ]) + .await + .unwrap(); + + let truncated = OpenAISSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(vec![Ok( + "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n", + )])) + .await + .unwrap_err(); + assert!(as_transient(&truncated).is_some(), "{truncated}"); + + let quota = OpenAIResponsesSSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(vec![Ok( + "data: {\"type\":\"error\",\"error\":{\"type\":\"insufficient_quota\",\"code\":\"insufficient_quota\",\"message\":\"m\"}}\n\n", + )])) + .await + .unwrap_err(); + assert!(as_transient("a).is_none(), "{quota}"); + + GeminiSSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(vec![Ok( + "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{}}}]}}]}\n\n", + )])) + .await + .unwrap(); + + let gemini = |chunks| async move { + GeminiSSEParser::new(Box::new(NoopSink)) + .parse_events(sse_response(chunks)) + .await + }; + // Thinking spent the whole `maxOutputTokens`: a clean close with no event. + gemini(vec![]).await.unwrap(); + let dropped = gemini(vec![Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "reset", + ))]) + .await + .unwrap_err(); + assert!(as_transient(&dropped).is_some(), "{dropped}"); + let truncated = gemini(vec![Ok( + "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi\"}]}}]}\n\n", + )]) + .await + .unwrap_err(); + assert!(as_transient(&truncated).is_some(), "{truncated}"); + } + /// The prompt-side counts arrive only on `message_start` and the completion total /// only on `message_delta`; a parser that keeps just the last one reports a request /// with no input tokens at all. diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index c401568868..cf7447ceba 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -40,6 +40,7 @@ use windmill_ai::proxy::{ fim::maybe_transform_fim_request, proxy_execution_mode, ProxyBuildArgs, ProxyExecutionMode, ProxyRequest, }; +use windmill_ai::retry::send_with_retries; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::db::UserDB; use windmill_common::error::{to_anyhow, Error, Result}; @@ -1188,15 +1189,11 @@ async fn global_proxy( } }; - let response = request.send().await.map_err(to_anyhow)?; + let response = send_with_retries(request).await; audit_global_ai_request(&db, &authed).await?; - if response.error_for_status_ref().is_err() { - let err_msg = response.text().await.unwrap_or("".to_string()); - return Err(Error::AIError(err_msg)); - } - + let response = response?; let status_code = response.status(); let headers = response.headers().clone(); let stream = response.bytes_stream(); @@ -1576,7 +1573,7 @@ async fn proxy( } }; - let response = request.send().await.map_err(to_anyhow)?; + let response = send_with_retries(request).await; let mut tx = db.begin().await?; @@ -1592,11 +1589,7 @@ async fn proxy( .await?; tx.commit().await?; - if response.error_for_status_ref().is_err() { - let err_msg = response.text().await.unwrap_or("".to_string()); - return Err(Error::AIError(err_msg)); - } - + let response = response?; let status_code = response.status(); let headers = response.headers().clone(); let is_sse = is_sse_response(&headers); diff --git a/backend/windmill-worker/src/ai/stream_event_processor.rs b/backend/windmill-worker/src/ai/stream_event_processor.rs index fe8a3b9b00..c4d17f080a 100644 --- a/backend/windmill-worker/src/ai/stream_event_processor.rs +++ b/backend/windmill-worker/src/ai/stream_event_processor.rs @@ -1,3 +1,8 @@ +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + use async_trait::async_trait; use windmill_ai::{query_builder::StreamEventSink, types::*}; use windmill_common::{error::Error, worker::Connection}; @@ -10,11 +15,13 @@ use crate::job_logger::append_result_stream; pub struct StreamEventProcessor { tx: Option>, pub handle: Option>, + /// Shared by every clone, so a sink lent to a parser counts into its owner. + streamed: Arc, } impl Clone for StreamEventProcessor { fn clone(&self) -> Self { - Self { tx: self.tx.clone(), handle: None } + Self { tx: self.tx.clone(), handle: None, streamed: self.streamed.clone() } } } @@ -48,13 +55,19 @@ impl StreamEventProcessor { } }); - Self { tx: Some(tx), handle: Some(handle) } + Self { tx: Some(tx), handle: Some(handle), streamed: Arc::default() } } /// Create a silent StreamEventProcessor that only accumulates events locally /// without persisting them to the database. Used when streaming is disabled. pub fn new_silent() -> Self { - Self { tx: None, handle: None } + Self { tx: None, handle: None, streamed: Arc::default() } + } + + /// How many events have been streamed to the job's readers. A silent processor + /// streams none. + pub fn streamed_events(&self) -> usize { + self.streamed.load(Ordering::Relaxed) } pub fn to_handle(self) -> Option> { @@ -79,6 +92,7 @@ impl StreamEventSink for StreamEventProcessor { Ok(event_json) => { let event_json = format!("{}\n", event_json); events_str.push_str(&event_json); + self.streamed.fetch_add(1, Ordering::Relaxed); if let Err(err) = tx .send(event_json.clone()) diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 7dd2382731..1b889aea34 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -43,6 +43,7 @@ use windmill_ai::{ common_outbound_headers, needs_unavailable_oauth_exchange, retain_effective_credentials, }, query_builder::{BuildRequestArgs, ParsedResponse}, + retry::{as_transient, into_final_error, response_error, send_error, Backoff, MAX_RETRIES}, types::*, utils::{pinned_ai_client_for, should_use_structured_output_tool}, }; @@ -1659,10 +1660,17 @@ pub async fn run_agent( .await; let mut retried_context = false; + let mut backoff = Backoff::default(); + let streamed_events = || { + stream_event_processor + .as_ref() + .map_or(0, |processor| processor.streamed_events()) + }; let (parsed, request_message_count) = loop { // How many messages this request carries, so the provider's prompt count can // later be told apart from what the response and its tool results add. let request_message_count = messages.context().len(); + let streamed_before = streamed_events(); // Handle AWS Bedrock provider specially using the official SDK let attempt: error::Result<_> = async { @@ -1818,7 +1826,7 @@ pub async fn run_agent( let resp = build_http_request(&endpoint, &auth_headers, request_body) .send() .await - .map_err(|e| Error::internal_err(format!("Failed to call API: {}", e)))?; + .map_err(|e| send_error(e, "Failed to call API"))?; match resp.error_for_status_ref() { Ok(_) => { @@ -1829,6 +1837,7 @@ pub async fn run_agent( } Err(e) => { let status = resp.status(); + let headers = resp.headers().clone(); let text = resp .text() .await @@ -1898,10 +1907,9 @@ pub async fn run_agent( query_builder = create_chat_completions_query_builder(&credentials); include_usage = true; } else { - return Err(Error::internal_err(format!( - "API error calling {}: {} - {}", - endpoint, e, text - ))); + let message = + format!("API error calling {}: {} - {}", endpoint, e, text); + return Err(response_error(status, &headers, &text, message)); } } } @@ -1961,7 +1969,45 @@ pub async fn run_agent( return Err(error); } } - Err(error) => return Err(error), + Err(error) => { + let Some(transient) = as_transient(&error) else { + return Err(error); + }; + // Readers have no way to take back what they were shown, so a call + // that already streamed part of its answer fails as it is. + if streamed_events() != streamed_before { + append_logs( + &job.id, + &job.workspace_id, + "AI provider call failed after part of its answer had streamed; not retried.\n".to_string(), + conn, + ) + .await; + return Err(into_final_error(error)); + } + let Some(delay) = backoff.next_delay(transient) else { + return Err(into_final_error(error)); + }; + append_logs( + &job.id, + &job.workspace_id, + format!( + "AI provider call failed: {}. Retrying in {:.1}s (retry {}/{MAX_RETRIES}).\n", + transient.message, + delay.as_secs_f64(), + backoff.retries() + ), + conn, + ) + .await; + let mut cancelled = cancel_rx.clone(); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + Ok(_) = cancelled.wait_for(|cancelled| *cancelled) => { + return Err(Error::ExecutionErr("Job cancelled".to_string())); + } + } + } } }; From 70db567a78b1e1bc9e2dcc9ff2139951d778117e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 9 Oct 2026 20:06:01 +0200 Subject: [PATCH 140/174] fix(cli): count superadmins as able to preserve ownership on push (#11653) Claude-Session: https://claude.ai/code/session_011BzU9zd53JsTShCsbKzYzB Co-authored-by: Claude Opus 5.5 (1M context) --- cli/src/core/permissioned_as.ts | 8 ++++-- cli/test/permissioned_as_context_unit.test.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 cli/test/permissioned_as_context_unit.test.ts diff --git a/cli/src/core/permissioned_as.ts b/cli/src/core/permissioned_as.ts index d0301373da..87ee37a8cb 100644 --- a/cli/src/core/permissioned_as.ts +++ b/cli/src/core/permissioned_as.ts @@ -34,10 +34,14 @@ export async function buildPermissionedAsContext( return undefined; } const user = await wmill.whoami({ workspace }); + // A superadmin acts as admin in every workspace, so the backend preserves + // ownership for them even when their own `usr` row there is not an admin. const userIsAdminOrDeployer = - user.is_admin || (user.groups ?? []).includes("wm_deployers"); + user.is_admin || + user.is_super_admin || + (user.groups ?? []).includes("wm_deployers"); log.debug( - `permissioned_as: user=${user.email}, is_admin=${user.is_admin}, groups=${JSON.stringify(user.groups)}, isAdminOrDeployer=${userIsAdminOrDeployer}` + `permissioned_as: user=${user.email}, is_admin=${user.is_admin}, is_super_admin=${user.is_super_admin}, groups=${JSON.stringify(user.groups)}, isAdminOrDeployer=${userIsAdminOrDeployer}` ); return { userCache: new Map(), diff --git a/cli/test/permissioned_as_context_unit.test.ts b/cli/test/permissioned_as_context_unit.test.ts new file mode 100644 index 0000000000..33a24bf3a1 --- /dev/null +++ b/cli/test/permissioned_as_context_unit.test.ts @@ -0,0 +1,27 @@ +/** + * Regression guard: a superadmin whose `usr` row in the workspace is not an + * admin still preserves ownership on the backend, so the CLI must not refuse + * their push as if it would reassign every item to them. + */ + +import { expect, test } from "bun:test"; +import { mockServices } from "./mock_services.ts"; + +mockServices({ + whoami: async () => ({ + email: "super@windmill.dev", + username: "super", + is_admin: false, + is_super_admin: true, + groups: [], + }), +}); + +const { buildPermissionedAsContext } = await import( + "../src/core/permissioned_as.ts" +); + +test("a superadmin with a non-admin membership counts as able to preserve", async () => { + const ctx = await buildPermissionedAsContext("w", "v1"); + expect(ctx?.userIsAdminOrDeployer).toBe(true); +}); From 8556a7bebe093e0bd61c33d67415fd553f0070ad Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 9 Oct 2026 14:20:22 -0400 Subject: [PATCH 141/174] fix: harden app secret validation (#11655) Claude-Session: https://claude.ai/code/session_01Kv72vdCDggCZEjJSmNCwnX Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-api/src/apps.rs | 60 ++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 818a284e96..54095f0dc6 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -2109,18 +2109,23 @@ async fn get_id_from_secret( prefix: Option<&str>, ) -> Result { let mc = build_crypt(db, w_id).await?; - let decrypted = mc - .decrypt_bytes_to_bytes(&(hex::decode(secret)?)) - .map_err(|e| Error::internal_err(e.to_string()))?; - let mut bytes = str::from_utf8(&decrypted).map_err(to_anyhow)?; - if let Some(prefix) = prefix { - if !bytes.starts_with(prefix) { - return Err(Error::BadRequest("Invalid secret".to_string())); - } - bytes = bytes.strip_prefix(prefix).unwrap_or(""); + id_from_secret(&mc, &secret, prefix) + .ok_or_else(|| Error::BadRequest("Invalid secret".to_string())) +} + +/// The secret comes from the URL of unauthenticated routes, so every way it can be invalid +/// must produce the same response: a caller must never learn why a secret was rejected. +fn id_from_secret( + mc: &magic_crypt::MagicCrypt256, + secret: &str, + prefix: Option<&str>, +) -> Option { + let decrypted = mc.decrypt_bytes_to_bytes(&hex::decode(secret).ok()?).ok()?; + let plaintext = String::from_utf8(decrypted).ok()?; + match prefix { + Some(prefix) => plaintext.strip_prefix(prefix)?.parse().ok(), + None => plaintext.parse().ok(), } - let id: i64 = bytes.parse().map_err(to_anyhow)?; - Ok(id) } async fn get_public_resource( @@ -6834,6 +6839,39 @@ mod policy_tests { } } +#[cfg(test)] +mod app_secret_tests { + use super::{id_from_secret, BUNDLE_SECRET_PREFIX}; + use magic_crypt::MagicCryptTrait; + + #[test] + fn every_invalid_secret_is_rejected_alike() { + let mc = magic_crypt::new_magic_crypt!("workspace-key", 256); + let other = magic_crypt::new_magic_crypt!("another-key", 256); + let valid = hex::encode(mc.encrypt_str_to_bytes("42")); + assert_eq!(id_from_secret(&mc, &valid, None), Some(42)); + let bundle = hex::encode(mc.encrypt_str_to_bytes(format!("{BUNDLE_SECRET_PREFIX}7"))); + assert_eq!( + id_from_secret(&mc, &bundle, Some(BUNDLE_SECRET_PREFIX)), + Some(7) + ); + + let mut tampered = mc.encrypt_str_to_bytes("42"); + *tampered.last_mut().unwrap() ^= 1; + for bad in [ + "not hex".to_string(), + hex::encode(&tampered), + hex::encode(other.encrypt_str_to_bytes("42")), + hex::encode(mc.encrypt_bytes_to_bytes(&[0xff, 0xfe])), + hex::encode(mc.encrypt_str_to_bytes("not a number")), + valid.clone(), + ] { + let prefix = (bad == valid).then_some(BUNDLE_SECRET_PREFIX); + assert_eq!(id_from_secret(&mc, &bad, prefix), None, "{bad}"); + } + } +} + #[cfg(test)] mod raw_app_file_key_tests { use super::validate_raw_app_path_keys; From 0a19d26d5f9623b805a7a4cd2e3fc44255a6efee Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 9 Oct 2026 21:12:12 +0200 Subject: [PATCH 142/174] chore(main): release 1.830.0 (#11650) * chore(main): release 1.830.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 20 +++ backend/Cargo.lock | 162 +++++++++--------- 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, 148 insertions(+), 128 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 79d95a4d54..fdce702331 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.829.0" + ".": "1.830.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f664a8aa97..80939b1b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.830.0](https://github.com/windmill-labs/windmill/compare/v1.829.0...v1.830.0) (2026-10-09) + + +### Features + +* add a jobs:run:dependencies token scope for lock generation ([#11643](https://github.com/windmill-labs/windmill/issues/11643)) ([1dc4ed5](https://github.com/windmill-labs/windmill/commit/1dc4ed5e7fb86c9658f1f877e7b92cd9dab5e283)) +* let operators compose full-code apps when the workspace grants it ([#11229](https://github.com/windmill-labs/windmill/issues/11229)) ([02e4697](https://github.com/windmill-labs/windmill/commit/02e4697157112581e9e2a9303180077126db714b)) +* operators get AI sessions ([#11443](https://github.com/windmill-labs/windmill/issues/11443)) ([338c4e9](https://github.com/windmill-labs/windmill/commit/338c4e921dc3185d886c8b040f253285adf20efb)) +* replace the hub_sync script with an opt-in daily server sync ([#11624](https://github.com/windmill-labs/windmill/issues/11624)) ([2b55a8a](https://github.com/windmill-labs/windmill/commit/2b55a8afbc8a86c6cebe97b1cab02c313b986f22)) +* retry transient AI provider failures before output reaches readers ([#11623](https://github.com/windmill-labs/windmill/issues/11623)) ([d3ddef0](https://github.com/windmill-labs/windmill/commit/d3ddef0b7ec4705aa578c4eca24b96e9152485bd)) +* stop offering deno for new scripts, ship it only on full images ([#11648](https://github.com/windmill-labs/windmill/issues/11648)) ([cda7883](https://github.com/windmill-labs/windmill/commit/cda78830fde1b96bd3de2674e23c245b50e3c051)) + + +### Bug Fixes + +* **cli:** count superadmins as able to preserve ownership on push ([#11653](https://github.com/windmill-labs/windmill/issues/11653)) ([70db567](https://github.com/windmill-labs/windmill/commit/70db567a78b1e1bc9e2dcc9ff2139951d778117e)) +* harden app secret validation ([#11655](https://github.com/windmill-labs/windmill/issues/11655)) ([8556a7b](https://github.com/windmill-labs/windmill/commit/8556a7bebe093e0bd61c33d67415fd553f0070ad)) +* open the chat + menu without waiting on the skills and mcp fetches ([#11645](https://github.com/windmill-labs/windmill/issues/11645)) ([1fea1aa](https://github.com/windmill-labs/windmill/commit/1fea1aa6609d644e02bac8d16186f9a2dfcb63cd)) +* say Running on a chat run card only once its job exists ([#11627](https://github.com/windmill-labs/windmill/issues/11627)) ([d046187](https://github.com/windmill-labs/windmill/commit/d046187e3edd01535240c6080b483afe154ae189)) + ## [1.829.0](https://github.com/windmill-labs/windmill/compare/v1.828.0...v1.829.0) (2026-10-09) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 64a152c962..fe235db7d1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.829.0" +version = "1.830.0" dependencies = [ "async-stream", "async-trait", @@ -14953,7 +14953,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14966,7 +14966,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "argon2", @@ -15106,7 +15106,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15129,7 +15129,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15146,7 +15146,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15172,7 +15172,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.829.0" +version = "1.830.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15182,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15199,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15221,7 +15221,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15244,7 +15244,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15260,7 +15260,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15282,7 +15282,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15304,7 +15304,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15318,7 +15318,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-nats", @@ -15354,7 +15354,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15380,7 +15380,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15408,7 +15408,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15430,7 +15430,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15450,7 +15450,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15492,7 +15492,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15521,7 +15521,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.829.0" +version = "1.830.0" dependencies = [ "lazy_static", "serde", @@ -15533,7 +15533,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.829.0" +version = "1.830.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15557,7 +15557,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15571,7 +15571,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.829.0" +version = "1.830.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15610,7 +15610,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.829.0" +version = "1.830.0" dependencies = [ "chrono", "lazy_static", @@ -15625,7 +15625,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15644,7 +15644,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.829.0" +version = "1.830.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15758,7 +15758,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.829.0" +version = "1.830.0" dependencies = [ "chrono", "futures", @@ -15778,7 +15778,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.829.0" +version = "1.830.0" dependencies = [ "regex", "serde", @@ -15795,7 +15795,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15822,7 +15822,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "futures", @@ -15839,7 +15839,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.829.0" +version = "1.830.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15855,7 +15855,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -15876,7 +15876,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -15907,7 +15907,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "arc-swap", @@ -15932,7 +15932,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-stream", @@ -15968,7 +15968,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "futures", @@ -15986,7 +15986,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.829.0" +version = "1.830.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15995,7 +15995,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -16007,7 +16007,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde_json", @@ -16019,7 +16019,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "gosyn", @@ -16031,7 +16031,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -16043,7 +16043,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde_json", @@ -16055,7 +16055,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "nu-parser", @@ -16066,7 +16066,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16077,7 +16077,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16089,7 +16089,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16100,7 +16100,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-recursion", @@ -16122,7 +16122,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde_json", @@ -16134,7 +16134,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -16148,7 +16148,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16165,7 +16165,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -16178,7 +16178,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde", @@ -16190,7 +16190,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -16208,7 +16208,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16224,7 +16224,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16240,7 +16240,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -16254,7 +16254,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-recursion", @@ -16293,7 +16293,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "const_format", @@ -16333,7 +16333,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.829.0" +version = "1.830.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16344,7 +16344,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-recursion", @@ -16379,7 +16379,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16403,7 +16403,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16436,7 +16436,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16463,7 +16463,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16496,7 +16496,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16516,7 +16516,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16550,7 +16550,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16587,7 +16587,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16610,7 +16610,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16634,7 +16634,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-nats", @@ -16658,7 +16658,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16693,7 +16693,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16721,7 +16721,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-trait", @@ -16746,7 +16746,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16765,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-once-cell", @@ -16884,7 +16884,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.829.0" +version = "1.830.0" dependencies = [ "bytes", "futures", @@ -17731,9 +17731,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.1+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" dependencies = [ "cc", "pkg-config", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4cdd6adae5..f50a858d52 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.829.0" +version = "1.830.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.829.0" +version = "1.830.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 2180b0656d..c0108282ee 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.829.0" +version = "1.830.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.829.0" +version = "1.830.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.829.0" +version = "1.830.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.829.0" +version = "1.830.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 2592d0bad4..8aebf20597 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.829.0" +version = "1.830.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 6e3c6ae615..43b1f776c2 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.829.0 + version: 1.830.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 6fbac6f559..34e4da5cf3 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.829.0"; +export const VERSION = "v1.830.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 308927fefe..03a55cff86 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.829.0", + "version": "1.830.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.829.0", + "version": "1.830.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 c1e57bcc1c..96637c376c 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.829.0", + "version": "1.830.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 837dc4cf57..21aeb07f60 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.829.0"; +export const VERSION = "1.830.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 774e9db0df..c8250f9eca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.829.0", + "version": "1.830.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.829.0", + "version": "1.830.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 25924993cf..ddfc6b410b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.829.0", + "version": "1.830.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 84ddb02401..4aee9bdf9f 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.829.0" +wmill = ">=1.830.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0e186c74bd..e3759ee6a6 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.829.0 + version: 1.830.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index fb5186c37a..8385126639 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.829.0' + ModuleVersion = '1.830.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 104455b7f9..411bc09cc8 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.829.0" +version = "1.830.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 ff319ebeea..ffd7c2300a 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.829.0", + "version": "1.830.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 b62ad56266..9adbfcec62 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.829.0", + "version": "1.830.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 bb120b1ee4..54f09db472 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.829.0 +1.830.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 6b3afa01f6..95c5128a13 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.829.0", + "version": "1.830.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.829.0", + "version": "1.830.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 7e00259bbb..6f2c2d7fed 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.829.0", + "version": "1.830.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 93e81ec1899778dc4d5e79d10cb08992b0da8c18 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 9 Oct 2026 21:54:57 +0200 Subject: [PATCH 143/174] [ee] chore: upgrade native runtime to current deno crates and v8 150 (#11657) * chore: upgrade the native runtime to the current deno crates and v8 150 Co-Authored-By: Claude Opus 5.5 * fix: keep data and blob fetches working under no_network Co-Authored-By: Claude Opus 5.5 * fix: pin the simdutf v8 archive and keep telemetry on http exporters Co-Authored-By: Claude Opus 5.5 * fix: force the http otlp exporter for native telemetry and pin v8 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/Cargo.lock | 1650 ++++++++++++----- backend/Cargo.toml | 52 +- backend/ee-repo-ref.txt | 2 +- backend/windmill-runtime-nativets/Cargo.toml | 13 +- backend/windmill-runtime-nativets/build.rs | 222 +-- backend/windmill-runtime-nativets/src/lib.rs | 269 ++- .../windmill-runtime-nativets/src/runtime.js | 121 +- .../src/smoke_tests.rs | 4 + .../tests/no_network.rs | 12 + .../tests/otel_e2e.rs | 15 +- .../tests/otel_init.rs | 11 +- flake.nix | 9 +- 12 files changed, 1485 insertions(+), 895 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index fe235db7d1..efd46867fb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -508,6 +508,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "asn1-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" +dependencies = [ + "asn1-rs-derive 0.4.0", + "asn1-rs-impl 0.1.0", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -515,7 +531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" dependencies = [ "asn1-rs-derive 0.5.1", - "asn1-rs-impl", + "asn1-rs-impl 0.2.0", "displaydoc", "nom", "num-traits", @@ -531,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive 0.6.0", - "asn1-rs-impl", + "asn1-rs-impl 0.2.0", "displaydoc", "nom", "num-traits", @@ -540,6 +556,18 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "asn1-rs-derive" version = "0.5.1" @@ -564,6 +592,17 @@ dependencies = [ "synstructure 0.13.2", ] +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "asn1-rs-impl" version = "0.2.0" @@ -974,11 +1013,23 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ - "aws-lc-sys", + "aws-lc-sys 0.45.0", "untrusted 0.7.1", "zeroize", ] +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "aws-lc-sys" version = "0.45.0" @@ -1349,7 +1400,7 @@ dependencies = [ "hyper-util", "pin-project-lite", "rustls 0.21.12", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", @@ -1492,44 +1543,17 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "rustc_version 0.4.1", + "rustc_version", "tracing", ] -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower 0.5.3", - "tower-layer", - "tower-service", -] - [[package]] name = "axum" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core 0.5.6", + "axum-core", "axum-macros", "bytes", "form_urlencoded", @@ -1558,26 +1582,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "axum-core" version = "0.5.6" @@ -1608,12 +1612,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "az" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" - [[package]] name = "backon" version = "1.6.0" @@ -1646,6 +1644,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base32" version = "0.5.1" @@ -1723,6 +1727,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease 0.2.37", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -2018,6 +2042,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "brotli" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 4.0.3", +] + [[package]] name = "brotli" version = "7.0.0" @@ -2212,6 +2247,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "candle-core" version = "0.9.2" @@ -2490,8 +2535,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" dependencies = [ "const-oid 0.9.6", - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", "x509-cert", ] @@ -2562,6 +2607,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-hex" +version = "1.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -2700,6 +2757,21 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2751,6 +2823,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.17.0" @@ -2842,6 +2920,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2859,7 +2953,32 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", ] [[package]] @@ -2899,6 +3018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -2926,8 +3046,8 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto 0.2.9", - "rustc_version 0.4.1", + "fiat-crypto", + "rustc_version", "subtle", "zeroize", ] @@ -3801,7 +3921,7 @@ checksum = "c72e0409b3dbd60a5bf296cbc273a8e36bb3a3aab6abac389f1891e187d5ce14" dependencies = [ "base64 0.22.1", "capacity_builder", - "deno_error 0.7.3", + "deno_error", "deno_media_type", "deno_terminal", "dprint-swc-ext", @@ -3835,37 +3955,29 @@ dependencies = [ "url", ] -[[package]] -name = "deno_console" -version = "0.209.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c0b8a65dcb7b38c22e5969c6454b3cb0839b7dcfb7a4f0d904de871a4c4416" -dependencies = [ - "deno_core", -] - [[package]] name = "deno_core" -version = "0.352.1" +version = "0.412.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf78f3f72ac8e09b18a588bc26a1b3c5ab853e7fb489889b2f5000654d34db5d" +checksum = "580cb2e0d797d75ff3dfc4a8f2b14aa7470f0fa31654a09a83d4cbb42b5eb9b4" dependencies = [ "anyhow", - "az", - "bincode", + "base64 0.22.1", "bit-set", "bit-vec 0.8.0", + "boxed_error", "bytes", - "capacity_builder", "cooked-waker", - "deno_core_icudata", - "deno_error 0.6.1", + "deno_error", "deno_ops", "deno_path_util", "deno_unsync", + "deno_v8", "futures", "indexmap 2.14.2", + "inventory", "libc", + "num-bigint", "parking_lot", "percent-encoding", "pin-project", @@ -3875,28 +3987,25 @@ dependencies = [ "smallvec", "sourcemap", "static_assertions", + "sys_traits", "thiserror 2.0.21", "tokio", "url", - "v8", + "uuid", "wasm_dep_analyzer", + "windows-sys 0.59.0", ] -[[package]] -name = "deno_core_icudata" -version = "0.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4dccb6147bb3f3ba0c7a48e993bfeb999d2c2e47a81badee80e2b370c8d695" - [[package]] name = "deno_crypto" -version = "0.223.0" +version = "0.273.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beb2568806b930b96fb6440ff16463befa73a36de3378986644e7134c9d52e8f" +checksum = "d9931d22328961b9a9c8d3b666a27312c1f3ddcc7ea78d8610ddfbc934ac61a0" dependencies = [ "aes 0.8.3", "aes-gcm", "aes-kw", + "argon2", "aws-lc-rs", "base64 0.22.1", "cbc", @@ -3904,12 +4013,15 @@ dependencies = [ "ctr", "curve25519-dalek", "deno_core", - "deno_error 0.6.1", - "deno_web", + "deno_crypto_provider", + "deno_error", "ecdsa", - "ed448-goldilocks", - "elliptic-curve", + "elliptic-curve 0.13.8", + "fips203", + "fips205", + "hmac 0.12.1", "num-traits", + "ocb3", "once_cell", "p256", "p384", @@ -3920,21 +4032,34 @@ dependencies = [ "serde_bytes", "sha1", "sha2 0.10.9", - "signature", - "spki", + "sha3", + "signature 2.2.0", + "spki 0.7.3", + "subtle", "thiserror 2.0.21", + "tiny-keccak", "tokio", "uuid", "x25519-dalek", ] [[package]] -name = "deno_error" -version = "0.6.1" +name = "deno_crypto_provider" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612ec3fc481fea759141b0c57810889b0a4fb6fee8f10748677bfe492fd30486" +checksum = "fafd673e5a4eefd6d65db358b00ff60488cfa1be6515406b4371935c2e2e6baa" dependencies = [ - "deno_error_macro 0.6.1", + "aws-lc-sys 0.40.0", + "ed448-goldilocks", +] + +[[package]] +name = "deno_error" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfafd2219b29886a71aecbb3449e462deed1b2c474dc5b12f855f0e58c478931" +dependencies = [ + "deno_error_macro", "libc", "serde", "serde_json", @@ -3942,32 +4067,11 @@ dependencies = [ "url", ] -[[package]] -name = "deno_error" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3007d3f1ea92ea503324ae15883aac0c2de2b8cf6fead62203ff6a67161007ab" -dependencies = [ - "deno_error_macro 0.7.3", - "libc", -] - [[package]] name = "deno_error_macro" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8380a4224d5d2c3f84da4d764c4326cac62e9a1e3d4960442d29136fc07be863" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "deno_error_macro" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b565e60a9685cdf312c888665b5f8647ac692a7da7e058a5e2268a466da8eaf" +checksum = "1c28ede88783f14cd8aae46ca89f230c226b40e4a81ab06fa52ed72af84beb2f" dependencies = [ "proc-macro2", "quote", @@ -3976,31 +4080,30 @@ dependencies = [ [[package]] name = "deno_features" -version = "0.6.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "487773bb24b92f3b88d1c9ef0d4d15888641a2e2ea4d4cdfd6fda12cae26317c" +checksum = "2fef16a40daeb19acfc5345aa5bb367a36ecffef18b7ad1a594f0f01fe7c20c5" dependencies = [ "deno_core", - "serde", - "serde_json", ] [[package]] name = "deno_fetch" -version = "0.233.0" +version = "0.283.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cbe158bec955790105ded69978b1245b97c65092f7bf184add344c0e78efffa" +checksum = "20e792f76d45e20cf6f1ad46157bbe1a0a895a6f09ec4d99b0911112be8e05e3" dependencies = [ + "async-compression", "base64 0.22.1", "bytes", "data-url", "deno_core", - "deno_error 0.6.1", + "deno_error", "deno_fs", + "deno_io", "deno_path_util", "deno_permissions", "deno_tls", - "dyn-clone", "error_reporter", "h2 0.4.20", "hickory-resolver", @@ -4011,8 +4114,7 @@ dependencies = [ "hyper-util", "ipnet", "percent-encoding", - "rustls-webpki 0.102.8", - "serde", + "rustls-webpki 0.103.15", "serde_json", "thiserror 2.0.21", "tokio", @@ -4021,62 +4123,69 @@ dependencies = [ "tokio-util", "tokio-vsock", "tower 0.5.3", - "tower-http", "tower-service", ] [[package]] name = "deno_fs" -version = "0.119.0" +version = "0.169.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca83a95cea7bcdf19dac1888ef6f04aff69d251640a4d085bc6089c06b4b181" +checksum = "a0b0ae3c55f4af9d33113ba4935828a2e9a7bfcbf4d3058533403b9ef332a966" dependencies = [ "async-trait", - "base32", "boxed_error", "deno_core", - "deno_error 0.6.1", + "deno_error", "deno_io", + "deno_maybe_sync", "deno_path_util", "deno_permissions", "filetime", "junction", "libc", - "nix 0.27.1", + "nix 0.30.1", "rand 0.8.5", "rayon", + "same-file", "serde", "thiserror 2.0.21", - "winapi", + "tokio", "windows-sys 0.59.0", ] [[package]] name = "deno_io" -version = "0.119.0" +version = "0.169.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597aac25be261a1bd545d6f47a80439c60e600c076f9847b3718a592883b2a73" +checksum = "ba495b6c141d34e49fb2dbe7ca746d7ac068b1962470776eb341c59059b758f6" dependencies = [ "async-trait", "deno_core", - "deno_error 0.6.1", + "deno_error", + "deno_permissions", "deno_subprocess_windows", "filetime", - "fs3", "libc", "log", - "nix 0.27.1", - "once_cell", + "nix 0.30.1", "os_pipe", "parking_lot", "pin-project", "rand 0.8.5", "tokio", "uuid", - "winapi", "windows-sys 0.59.0", ] +[[package]] +name = "deno_maybe_sync" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5e72b38015b5e478b8d739dfa818d563329e03a72d6ce5cfb2ecbcd7f8d41" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "deno_media_type" version = "0.3.3" @@ -4103,17 +4212,21 @@ dependencies = [ [[package]] name = "deno_net" -version = "0.201.0" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb88f1ea2762065d6cc0316372acc22696ab6004943aa029f80546f9faf1b491" +checksum = "19ba02152d978f30e2eb5f8d074b343363f53cb1364b5c304b3176da916a06c0" dependencies = [ "deno_core", - "deno_error 0.6.1", + "deno_error", "deno_features", "deno_permissions", + "deno_signals", "deno_tls", + "deno_tunnel", "hickory-proto", "hickory-resolver", + "libc", + "log", "pin-project", "quinn", "rustls-tokio-stream", @@ -4125,32 +4238,33 @@ dependencies = [ "tokio-vsock", "url", "web-transport-proto", + "x509-parser 0.15.1", ] [[package]] name = "deno_ops" -version = "0.228.1" +version = "0.288.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf8dbe5abf37d270bb853c5dfe45fbe3b1b6c453877cc11d7fe84e9862a6dbc" +checksum = "977748624aad80164dbe385c8c907bc0041c2e6849f92528e362e03132c64c52" dependencies = [ "indexmap 2.14.2", - "proc-macro-rules", "proc-macro2", "quote", "stringcase", "strum", "strum_macros", "syn 2.0.119", + "syn-match", "thiserror 2.0.21", ] [[package]] name = "deno_path_util" -version = "0.4.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516f813389095889776b81cc9108ff6f336fd9409b4b12fc0138aea23d2708e1" +checksum = "78c7e98943f0d068928906db0c7bde89de684fa32c6a8018caacc4cee2cdd72b" dependencies = [ - "deno_error 0.6.1", + "deno_error", "percent-encoding", "sys_traits", "thiserror 2.0.21", @@ -4159,77 +4273,100 @@ dependencies = [ [[package]] name = "deno_permissions" -version = "0.68.0" +version = "0.118.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa14d9c3fbba59836ffbaac7f736a2f48d4a5fc209a2103d1e1b898915232a5" +checksum = "f8e9d37215f52e29b5ad9d9065c476168ffb888cd2ad93b0ae153f22304851a0" dependencies = [ "capacity_builder", - "deno_error 0.6.1", + "chrono", + "deno_error", "deno_path_util", "deno_terminal", "deno_unsync", + "dunce", "fqdn", - "ipnetwork", + "ipnet", "libc", "log", - "nix 0.27.1", + "nix 0.30.1", "once_cell", "parking_lot", - "percent-encoding", "serde", "serde_json", "sys_traits", - "temp_deno_which", "thiserror 2.0.21", + "unicode-normalization", "url", - "winapi", + "which 8.0.6", + "windows-sys 0.59.0", +] + +[[package]] +name = "deno_signals" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038ed4b09a6d36c81ac436c4d10076e9e01cc67faf93d701c541887f8eeddf36" +dependencies = [ + "libc", + "signal-hook", + "thiserror 2.0.21", + "tokio", "windows-sys 0.59.0", ] [[package]] name = "deno_subprocess_windows" -version = "0.4.0" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc6b10059f0ccb14c6e0319c5275f0407fb2f9ffe405cd555700561999ea4bf" +checksum = "04d4f451b2f94d2847de4895d39ca5aa264b90af34e72fd79ddc1d0da05a5ea0" dependencies = [ - "fastrand 2.5.0", "futures-channel", "libc", + "rand 0.8.5", "windows-sys 0.59.0", ] [[package]] name = "deno_telemetry" -version = "0.31.0" +version = "0.81.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "377581966bd34e85ce230f7f4b1dee7e16c6bf7f845b4690648507ce7ba105d9" +checksum = "8831dc236ed1c66a90c8dfddab93ad44fbb28950fc476c78306b9100233ff694" dependencies = [ "async-trait", "deno_core", - "deno_error 0.6.1", + "deno_error", + "deno_net", + "deno_signals", + "deno_terminal", "deno_tls", + "flate2", "http-body-util", "hyper 1.12.0", "hyper-rustls 0.27.10", "hyper-util", "log", "once_cell", - "opentelemetry 0.27.1", - "opentelemetry-http 0.27.0", - "opentelemetry-otlp 0.27.0", - "opentelemetry-semantic-conventions 0.27.0", - "opentelemetry_sdk 0.27.1", + "opentelemetry 0.32.0", + "opentelemetry-http 0.32.0", + "opentelemetry-otlp 0.32.0", + "opentelemetry-proto 0.32.0", + "opentelemetry-semantic-conventions 0.32.1", + "opentelemetry_sdk 0.32.1", "pin-project", + "prost 0.14.4", "serde", + "sys_traits", "thiserror 2.0.21", "tokio", + "tokio-vsock", + "tower-service", ] [[package]] name = "deno_terminal" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f71c27009e0141dedd315f1dfa3ebb0a6ca4acce7c080fac576ea415a465f6" +checksum = "f3ba8041ae7319b3ca6a64c399df4112badcbbe0868b4517637647614bede4be" dependencies = [ "once_cell", "termcolor", @@ -4237,23 +4374,40 @@ dependencies = [ [[package]] name = "deno_tls" -version = "0.196.0" +version = "0.246.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7835eb6a8d114703b0293573fbb8885bc2c81e8c35363b9e89391dd3c541fa6" +checksum = "6229f7a00441b2acbf7fae58dd14bded0e835f212653402083430826e640f99d" dependencies = [ "deno_core", - "deno_error 0.6.1", + "deno_error", "deno_native_certs", - "rustls 0.23.35", + "log", + "rustls 0.23.40", "rustls-pemfile 2.2.0", "rustls-tokio-stream", - "rustls-webpki 0.102.8", + "rustls-webpki 0.103.15", "serde", "thiserror 2.0.21", "tokio", "webpki-roots 0.26.11", ] +[[package]] +name = "deno_tunnel" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e432929e0c167f2a003b963ee406c714fcd947a87d4f9edc7b3255e87be9881d" +dependencies = [ + "pin-project", + "quinn", + "serde", + "serde_json", + "socket2 0.6.5", + "thiserror 2.0.21", + "tokio", + "tracing", +] + [[package]] name = "deno_unsync" version = "0.4.4" @@ -4266,42 +4420,43 @@ dependencies = [ ] [[package]] -name = "deno_url" -version = "0.209.0" +name = "deno_v8" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cccbb10fb59f29b04161055a65f7fe83364f9e8317d4d2ce3bd7979faf63a87" +checksum = "85cdff08dedf79da6a40f97554f47d59d419ba3df8061b8b31789cd2cbe00433" dependencies = [ - "deno_core", - "deno_error 0.6.1", - "urlpattern", + "v8", + "v8x", ] [[package]] name = "deno_web" -version = "0.240.0" +version = "0.290.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6b8a97cc90b6aaea20fe200cfa7e5b6953dd33ecda0af50d6d360387c33df4" +checksum = "7510967ba4ec695959c1a518748c3b564fd9ca7a8f3eb13bd397707a3ca232da" dependencies = [ "async-trait", - "base64-simd", + "brotli 6.0.0", "bytes", + "cssparser", "deno_core", - "deno_error 0.6.1", - "deno_permissions", + "deno_error", "encoding_rs", "flate2", "futures", "serde", "thiserror 2.0.21", "tokio", + "unicode-normalization", + "urlpattern", "uuid", ] [[package]] name = "deno_webidl" -version = "0.209.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20bbfac0cf15918f7cbd0b55c366ac20ad65c825f362fa684c3104984254d3b" +checksum = "40af42313723a8d3114c4e5b007abd1cab4400a0b68929794c556b2e792409c7" dependencies = [ "deno_core", ] @@ -4319,6 +4474,30 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" +dependencies = [ + "asn1-rs 0.5.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -4367,6 +4546,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-io" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58cc7f4740088458993d183a200fe56e62378736f94bf0e2dd45807407e7bb94" +dependencies = [ + "derive-io-macros", + "tokio", +] + +[[package]] +name = "derive-io-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d0a1bd7eeab72097740967d03d53db5fbaf8e3c0dd471ebdefa43ce445a20a6" +dependencies = [ + "proc-macro2", +] + [[package]] name = "derive_builder" version = "0.12.0" @@ -4467,7 +4665,7 @@ checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "proc-macro2", "quote", - "rustc_version 0.4.1", + "rustc_version", "syn 2.0.119", ] @@ -4524,6 +4722,38 @@ dependencies = [ "ctutils", ] +[[package]] +name = "diplomat" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b37a6cea72eb6df5143d3e091d4c34cb0cf4046dd1ddcc082988886fe1f809" +dependencies = [ + "diplomat_core", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "diplomat-runtime" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae517853d9f82b2d144393b1c32c34c46c2e361de2b6c44a8fdfce23beb211cd" + +[[package]] +name = "diplomat_core" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b18e806f9ecb92df828d080efe47984ae1193958295d4a84fb8b32c9a1aa1b" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "smallvec", + "strck", + "syn 3.0.6", +] + [[package]] name = "dirs" version = "4.0.0" @@ -4681,6 +4911,21 @@ dependencies = [ "text_lines", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "duct" version = "0.13.7" @@ -4727,12 +4972,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", - "elliptic-curve", + "elliptic-curve 0.13.8", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -4741,8 +4986,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] @@ -4756,21 +5001,35 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2 0.10.9", - "signature", + "signature 2.2.0", "subtle", "zeroize", ] [[package]] -name = "ed448-goldilocks" -version = "0.8.3" +name = "ed448" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06924531e9e90130842b012e447f85bdaf9161bc8a0f8092be8cb70b01ebe092" +checksum = "ae112a25f86ae3598d4e8533ed1e65149cec6eb21918e7a6f4c06dddec370263" dependencies = [ - "fiat-crypto 0.1.20", - "hex", + "pkcs8 0.11.0", + "signature 3.0.0", +] + +[[package]] +name = "ed448-goldilocks" +version = "0.14.0-pre.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b805154de2e68f59874ec217ca36790dcffe500cd872c60fe509d28d0814a74d" +dependencies = [ + "ed448", + "elliptic-curve 0.14.1", + "hash2curve", + "rand_core 0.10.1", + "serdect 0.4.3", + "shake", + "signature 3.0.0", "subtle", - "zeroize", ] [[package]] @@ -4800,20 +5059,39 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "base64ct", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array", - "group", + "group 0.13.0", "hkdf", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "serde_json", - "serdect", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -5012,6 +5290,18 @@ dependencies = [ "regex-syntax 0.8.11", ] +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher 1.0.4", +] + [[package]] name = "fastdivide" version = "0.4.2" @@ -5044,10 +5334,14 @@ dependencies = [ ] [[package]] -name = "fiat-crypto" -version = "0.1.20" +name = "ff" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] [[package]] name = "fiat-crypto" @@ -5071,6 +5365,30 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fips203" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8bdb6454f692ca2a2b45cd554c6828c639d7f9c968cf83a678899ec4443a280" +dependencies = [ + "rand_core 0.6.4", + "sha3", + "subtle", + "zeroize", +] + +[[package]] +name = "fips205" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5626bf5534df4ebdbd2536465d7eaa8a9dc2cdeb7e036e0ecf291dcc80ffb6" +dependencies = [ + "rand_core 0.6.4", + "sha2 0.10.9", + "sha3", + "zeroize", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -5090,7 +5408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ "bitflags 2.13.2", - "rustc_version 0.4.1", + "rustc_version", ] [[package]] @@ -5173,9 +5491,9 @@ dependencies = [ [[package]] name = "fqdn" -version = "0.3.12" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb540cf7bc4fe6df9d8f7f0c974cfd0dce8ed4e9e8884e73433b503ee78b4e7d" +checksum = "886ac788f62d16d6b0f26b2fa762b34ef16ebfb4b624c2c15fbcadc9173c0f72" [[package]] name = "from_variant" @@ -5193,17 +5511,6 @@ version = "1.20260821.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28c70238056ad1dbfdf035222a571d2e9baf5b6a4c308cfa87bc8cbe7f2f3840" -[[package]] -name = "fs3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb17cf6ed704f72485332f6ab65257460c4f9f3083934cf402bf9f5b3b600a90" -dependencies = [ - "libc", - "rustc_version 0.2.3", - "winapi", -] - [[package]] name = "fs4" version = "0.13.1" @@ -5721,7 +6028,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "886aa8ec755382a1fdf4651f6e6ec01f2f3bf49f2cb0f068b9a74cafd574a715" dependencies = [ - "prost", + "prost 0.13.5", "prost-types", "tonic 0.12.3", ] @@ -5783,11 +6090,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gzip-header" version = "1.1.0" @@ -5850,6 +6168,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash2curve" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eaf40612d7d854743e7189228a6d528f0f6e8502cf6a0cb831d28a218b7f3f6" +dependencies = [ + "digest 0.11.3", + "elliptic-curve 0.14.1", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -5997,11 +6325,10 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.25.0-alpha.5" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d00147af6310f4392a31680db52a3ed45a2e0f68eb18e8c3fe5537ecc96d9e2" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ - "async-recursion", "async-trait", "cfg-if", "data-encoding", @@ -6013,6 +6340,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.9.5", + "ring 0.17.14", "serde", "thiserror 2.0.21", "tinyvec", @@ -6023,9 +6351,9 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.25.0-alpha.5" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5762f69ebdbd4ddb2e975cd24690bf21fe6b2604039189c26acddbc427f12887" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", @@ -6217,7 +6545,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -6344,7 +6674,7 @@ dependencies = [ "hyper 1.12.0", "hyper-util", "log", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-native-certs 0.8.4", "tokio", "tokio-rustls 0.26.6", @@ -6461,6 +6791,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" + [[package]] name = "icu_collections" version = "2.3.0" @@ -6733,15 +7085,6 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" -[[package]] -name = "ipnetwork" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" -dependencies = [ - "serde", -] - [[package]] name = "is-macro" version = "0.3.8" @@ -6808,6 +7151,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ixdtf" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3" + [[package]] name = "jiff" version = "0.2.38" @@ -6887,7 +7236,7 @@ checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", - "rustc_version 0.4.1", + "rustc_version", "simd_cesu8", "syn 2.0.119", ] @@ -6995,7 +7344,7 @@ dependencies = [ "pem", "serde", "serde_json", - "signature", + "signature 2.2.0", "simple_asn1", "zeroize", ] @@ -7022,6 +7371,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + [[package]] name = "keyed_priority_queue" version = "0.4.2" @@ -7093,7 +7461,7 @@ dependencies = [ "k8s-openapi", "kube-core", "pem", - "rustls 0.23.35", + "rustls 0.23.40", "secrecy", "serde", "serde_json", @@ -7582,7 +7950,7 @@ dependencies = [ "base64 0.22.1", "gethostname", "mail-builder", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-pki-types", "smtp-proto", "tokio", @@ -8072,6 +8440,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -8291,7 +8660,6 @@ checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", - "rand 0.8.5", "serde", ] @@ -8490,6 +8858,18 @@ dependencies = [ "web-time", ] +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher 0.4.4", + "ctr", + "subtle", +] + [[package]] name = "odpic-sys" version = "0.1.1" @@ -8499,6 +8879,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bedf36ffb6ba96c2eb7144ef6270557b52e54b20c0a8e1eb2ff99a6c6959bff" +dependencies = [ + "asn1-rs 0.5.2", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -8528,6 +8917,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -8667,23 +9060,23 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.27.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab70038c28ed37b97d8ed414b6429d343a8bbf44c9f79ec854f3a643029ba6d7" +checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" dependencies = [ "futures-core", "futures-sink", "js-sys", "pin-project-lite", - "thiserror 1.0.69", + "thiserror 2.0.21", "tracing", ] [[package]] name = "opentelemetry" -version = "0.30.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" dependencies = [ "futures-core", "futures-sink", @@ -8705,18 +9098,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "opentelemetry-http" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a8a7f5f6ba7c1b286c2fbca0454eaba116f63bbe69ed250b642d36fbb04d80" -dependencies = [ - "async-trait", - "bytes", - "http 1.5.0", - "opentelemetry 0.27.1", -] - [[package]] name = "opentelemetry-http" version = "0.30.0" @@ -8731,24 +9112,15 @@ dependencies = [ ] [[package]] -name = "opentelemetry-otlp" -version = "0.27.0" +name = "opentelemetry-http" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91cf61a1868dacc576bf2b2a1c3e9ab150af7272909e80085c3173384fe11f76" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", - "futures-core", + "bytes", "http 1.5.0", - "opentelemetry 0.27.1", - "opentelemetry-http 0.27.0", - "opentelemetry-proto 0.27.0", - "opentelemetry_sdk 0.27.1", - "prost", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tonic 0.12.3", - "tracing", + "opentelemetry 0.32.0", ] [[package]] @@ -8762,7 +9134,7 @@ dependencies = [ "opentelemetry-http 0.30.0", "opentelemetry-proto 0.30.0", "opentelemetry_sdk 0.30.0", - "prost", + "prost 0.13.5", "reqwest 0.12.28", "thiserror 2.0.21", "tokio", @@ -8771,17 +9143,19 @@ dependencies = [ ] [[package]] -name = "opentelemetry-proto" -version = "0.27.0" +name = "opentelemetry-otlp" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e05acbfada5ec79023c85368af14abd0b307c015e9064d249b2a950ef459a6" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "hex", - "opentelemetry 0.27.1", - "opentelemetry_sdk 0.27.1", - "prost", - "serde", - "tonic 0.12.3", + "http 1.5.0", + "opentelemetry 0.32.0", + "opentelemetry-http 0.32.0", + "opentelemetry-proto 0.32.0", + "opentelemetry_sdk 0.32.1", + "prost 0.14.4", + "serde_json", + "thiserror 2.0.21", ] [[package]] @@ -8794,16 +9168,24 @@ dependencies = [ "hex", "opentelemetry 0.30.0", "opentelemetry_sdk 0.30.0", - "prost", + "prost 0.13.5", "serde", "tonic 0.13.1", ] [[package]] -name = "opentelemetry-semantic-conventions" -version = "0.27.0" +name = "opentelemetry-proto" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc1b6902ff63b32ef6c489e8048c5e253e2e4a803ea3ea7e783914536eb15c52" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "base64 0.22.1", + "const-hex", + "opentelemetry 0.32.0", + "opentelemetry_sdk 0.32.1", + "prost 0.14.4", + "serde", +] [[package]] name = "opentelemetry-semantic-conventions" @@ -8812,25 +9194,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2" [[package]] -name = "opentelemetry_sdk" -version = "0.27.1" +name = "opentelemetry-semantic-conventions" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "231e9d6ceef9b0b2546ddf52335785ce41252bc7474ee8ba05bfad277be13ab8" -dependencies = [ - "async-trait", - "futures-channel", - "futures-executor", - "futures-util", - "glob", - "opentelemetry 0.27.1", - "percent-encoding", - "rand 0.8.5", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tokio-stream", - "tracing", -] +checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47" [[package]] name = "opentelemetry_sdk" @@ -8850,6 +9217,24 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry 0.32.0", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.21", + "tokio", + "tokio-stream", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -8939,7 +9324,7 @@ checksum = "3cae83056e7cb770211494a0ecf66d9fa7eba7d00977e5bb91f0e925b40b937f" dependencies = [ "cbc", "cms", - "der", + "der 0.7.10", "des 0.8.1", "hex", "hmac 0.12.1", @@ -8960,7 +9345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "primeorder", "sha2 0.10.9", ] @@ -8972,7 +9357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "primeorder", "sha2 0.10.9", ] @@ -8983,9 +9368,9 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct", + "base16ct 0.2.0", "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "primeorder", "rand_core 0.6.4", "sha2 0.10.9", @@ -9224,7 +9609,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", + "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -9243,6 +9628,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ + "phf_macros 0.13.1", "phf_shared 0.13.1", "serde", ] @@ -9253,7 +9639,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", ] @@ -9267,19 +9653,42 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.5.0", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -9380,9 +9789,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -9393,9 +9802,9 @@ checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" dependencies = [ "cms", "const-oid 0.9.6", - "der", + "der 0.7.10", "digest 0.10.7", - "spki", + "spki 0.7.3", "x509-cert", "zeroize", ] @@ -9408,11 +9817,11 @@ checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ "aes 0.8.3", "cbc", - "der", + "der 0.7.10", "pbkdf2", "scrypt", "sha2 0.10.9", - "spki", + "spki 0.7.3", ] [[package]] @@ -9421,8 +9830,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.1", ] [[package]] @@ -9608,7 +10027,7 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", ] [[package]] @@ -9643,29 +10062,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-rules" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c277e4e643ef00c1233393c673f655e3672cf7eb3ba08a00bdd0ea59139b5f" -dependencies = [ - "proc-macro-rules-macros", - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "proc-macro-rules-macros" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "207fffb0fe655d1d47f6af98cc2793405e85929bdbc420d685554ff07be27ac7" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "proc-macro-utils" version = "0.10.0" @@ -9739,6 +10135,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.13.2", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax 0.8.11", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -9746,7 +10157,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -9762,13 +10183,26 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "prost-types" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost", + "prost 0.13.5", ] [[package]] @@ -9886,7 +10320,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.3", - "rustls 0.23.35", + "rustls 0.23.40", "socket2 0.6.5", "thiserror 2.0.21", "tokio", @@ -9902,13 +10336,14 @@ checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", + "fastbloom", "getrandom 0.4.3", "lru-slab", "rand 0.10.3", "rand_pcg", "ring 0.17.14", "rustc-hash 2.1.3", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-pki-types", "slab", "thiserror 2.0.21", @@ -10094,6 +10529,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -10373,7 +10817,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-native-certs 0.8.4", "rustls-pki-types", "serde", @@ -10420,7 +10864,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -10476,6 +10920,16 @@ dependencies = [ "wasmtimer", ] +[[package]] +name = "resb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d392791f3c6802a1905a509e9d1a6039cbbcb5e9e00e5a6d3661f7c874f390" +dependencies = [ + "potential_utf", + "serde_core", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -10666,10 +11120,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -10765,22 +11219,13 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver 0.9.0", -] - [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.28", + "semver", ] [[package]] @@ -10860,9 +11305,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -10881,7 +11326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" dependencies = [ "log", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-native-certs 0.7.3", "rustls-pki-types", "rustls-webpki 0.103.15", @@ -10963,7 +11408,7 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-native-certs 0.8.4", "rustls-platform-verifier-android", "rustls-webpki 0.103.15", @@ -10981,12 +11426,13 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-tokio-stream" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa7dc7c991d9164e55bbf1558029eb5b84d32cc4d61a7df5b8641b2deedc4b3" +checksum = "0560d12c0d8c672f849197de91b9ee61f5bf9aa024c97aaeeb112ec2f6c347fd" dependencies = [ + "derive-io", "futures", - "rustls 0.23.35", + "rustls 0.23.40", "socket2 0.5.10", "tokio", ] @@ -11288,11 +11734,25 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", - "serdect", + "pkcs8 0.10.2", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.2", + "hybrid-array", "subtle", "zeroize", ] @@ -11342,27 +11802,12 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - [[package]] name = "seq-macro" version = "0.3.6" @@ -11541,16 +11986,16 @@ dependencies = [ [[package]] name = "serde_v8" -version = "0.261.1" +version = "0.321.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3495190857461e87a2716141043218aad5281f219f54a03b7ebbe605b3b931df" +checksum = "fd3b6f6140192f569b88997e2ea7afeef6aa38b64374e84784ea1d1ea35679d3" dependencies = [ - "deno_error 0.6.1", + "deno_error", + "deno_v8", "num-bigint", "serde", "smallvec", "thiserror 2.0.21", - "v8", ] [[package]] @@ -11620,7 +12065,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] @@ -11695,6 +12150,27 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -11735,6 +12211,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -11751,9 +12237,9 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "zeroize", ] @@ -11767,6 +12253,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -11779,7 +12275,7 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ - "rustc_version 0.4.1", + "rustc_version", "simdutf8", ] @@ -11947,7 +12443,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.2", ] [[package]] @@ -11962,6 +12468,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sql-builder" version = "3.1.1" @@ -12041,7 +12553,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls 0.23.35", + "rustls 0.23.40", "serde", "serde_json", "sha2 0.10.9", @@ -12238,6 +12750,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strck" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f" +dependencies = [ + "unicode-ident", +] + [[package]] name = "string_enum" version = "1.0.2" @@ -12751,6 +13272,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn-match" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b8f0a9004d6aafa6a588602a1119e6cdaacec9921aa1605383e6e7d6258fd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -12760,6 +13292,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -12793,11 +13337,14 @@ dependencies = [ [[package]] name = "sys_traits" -version = "0.1.16" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4707edf3196e8037ee45018d1bb1bfb233b0e4fc440fa3d3f25bc69bfdaf26" +checksum = "c7a35f11f9773f11930a7d1e6276766f1577400ee2530b7f0cf3844c2dcbf12b" dependencies = [ + "junction", + "libc", "sys_traits_macros", + "windows-sys 0.59.0", ] [[package]] @@ -13052,15 +13599,6 @@ dependencies = [ "rustls-pemfile 2.2.0", ] -[[package]] -name = "temp_deno_which" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366c5ccd670145885feb6efd6bbf2478ed236c4c3839046fcc8e2a1a84c51091" -dependencies = [ - "either", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -13074,6 +13612,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "temporal_capi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48eb9ca4ca22caaa48d1e7a534ad3fd598a23d1a71945e8ad5ac0be501fa5f2" +dependencies = [ + "diplomat", + "diplomat-runtime", + "icu_calendar", + "icu_locale_core", + "num-traits", + "temporal_rs", + "timezone_provider", + "writeable", + "zoneinfo64", +] + +[[package]] +name = "temporal_rs" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a978fef907a27736827d336c0bfaab14fb8a7509dd6dc2b43c44e51d59aca5b" +dependencies = [ + "calendrical_calculations", + "core_maths", + "icu_calendar", + "icu_locale_core", + "ixdtf", + "num-traits", + "timezone_provider", + "tinystr", + "writeable", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -13274,6 +13846,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "timezone_provider" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ec4f4eebb4817fde02a411aedc7ac5f66c89c68c49e5d4b17a8139f077af52" +dependencies = [ + "tinystr", + "zerotrie", + "zerovec", + "zoneinfo64", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -13350,9 +13934,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.46.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -13363,10 +13947,10 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2 0.5.10", + "socket2 0.6.5", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -13490,7 +14074,7 @@ version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ - "rustls 0.23.35", + "rustls 0.23.40", "tokio", ] @@ -13669,13 +14253,10 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ - "async-stream", "async-trait", - "axum 0.7.9", "base64 0.22.1", "bytes", "flate2", - "h2 0.4.20", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -13684,9 +14265,8 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", + "prost 0.13.5", "rustls-pemfile 2.2.0", - "socket2 0.5.10", "tokio", "tokio-rustls 0.26.6", "tokio-stream", @@ -13704,7 +14284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "flate2", @@ -13717,7 +14297,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", + "prost 0.13.5", "rustls-native-certs 0.8.4", "socket2 0.5.10", "tokio", @@ -13775,7 +14355,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" dependencies = [ - "axum-core 0.5.6", + "axum-core", "cookie", "futures-util", "http 1.5.0", @@ -14129,6 +14709,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-char-property" version = "0.9.0" @@ -14344,7 +14930,7 @@ dependencies = [ "log", "native-tls", "once_cell", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-pki-types", "serde", "serde_json", @@ -14374,13 +14960,13 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "0f805818f843b548bacc19609eb3619dd2850e54746f5cada37927393c2ef4ec" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -14428,20 +15014,36 @@ dependencies = [ [[package]] name = "v8" -version = "137.3.0" +version = "150.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595" +checksum = "42a978ff11f15b24e5c05a7123cf2b68f41e763546699781a924ef4e2cf43a49" dependencies = [ - "bindgen 0.71.1", + "bindgen 0.72.1", "bitflags 2.13.2", "fslock", "gzip-header", "home", "miniz_oxide 0.8.9", "paste", + "temporal_capi", "which 6.0.3", ] +[[package]] +name = "v8x" +version = "149.4.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3701ffb3692a24808503d17f758b0a3f688d2c451779247982566b5941033e63" +dependencies = [ + "bindgen 0.70.1", + "bitflags 2.13.2", + "cc", + "paste", + "temporal_capi", + "temporal_rs", + "timezone_provider", +] + [[package]] name = "valuable" version = "0.1.1" @@ -14661,11 +15263,11 @@ dependencies = [ [[package]] name = "wasm_dep_analyzer" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51cf5f08b357e64cd7642ab4bbeb11aecab9e15520692129624fb9908b8df2c" +checksum = "a10e6b67c951a84de7029487e0e0a496860dae49f6699edd279d5ff35b8fbf54" dependencies = [ - "deno_error 0.6.1", + "deno_error", "thiserror 2.0.21", ] @@ -14766,6 +15368,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" + [[package]] name = "whoami" version = "1.6.1" @@ -14836,7 +15444,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -14856,7 +15464,7 @@ dependencies = [ "rdkafka", "reqwest 0.13.5", "rumqttc", - "rustls 0.23.35", + "rustls 0.23.40", "serde", "serde_derive", "serde_json", @@ -14955,7 +15563,7 @@ dependencies = [ name = "windmill-alerting" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "serde_json", @@ -14977,7 +15585,7 @@ dependencies = [ "async_zip", "aws-sdk-config", "aws-sigv4", - "axum 0.8.9", + "axum", "base32", "base64 0.22.1", "bytes", @@ -15019,7 +15627,7 @@ dependencies = [ "reqwest 0.13.5", "rsa", "rust-embed", - "rustls 0.23.35", + "rustls 0.23.40", "samael", "serde", "serde_json", @@ -15108,7 +15716,7 @@ dependencies = [ name = "windmill-api-agent-workers" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "http 1.5.0", "hyper 1.12.0", @@ -15131,7 +15739,7 @@ dependencies = [ name = "windmill-api-assets" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "serde_json", @@ -15149,7 +15757,7 @@ name = "windmill-api-auth" version = "1.830.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "chrono", "http 1.5.0", "itertools 0.14.0", @@ -15184,7 +15792,7 @@ dependencies = [ name = "windmill-api-configs" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "itertools 0.14.0", "serde", @@ -15201,7 +15809,7 @@ dependencies = [ name = "windmill-api-debug" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "ed25519-dalek", @@ -15224,7 +15832,7 @@ name = "windmill-api-embeddings" version = "1.830.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "candle-core", "candle-nn", "candle-transformers", @@ -15246,7 +15854,7 @@ dependencies = [ name = "windmill-api-flow-conversations" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "sql-builder", @@ -15262,7 +15870,7 @@ dependencies = [ name = "windmill-api-flows" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "hyper 1.12.0", "serde", @@ -15284,7 +15892,7 @@ dependencies = [ name = "windmill-api-groups" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "globset", "lazy_static", @@ -15306,7 +15914,7 @@ dependencies = [ name = "windmill-api-inputs" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "serde_json", @@ -15325,7 +15933,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.8.9", + "axum", "base64 0.22.1", "futures", "hex", @@ -15357,7 +15965,7 @@ name = "windmill-api-jobs" version = "1.830.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "http 1.5.0", @@ -15382,7 +15990,7 @@ dependencies = [ name = "windmill-api-npm-proxy" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "flate2", "futures", @@ -15390,7 +15998,7 @@ dependencies = [ "lazy_static", "quick_cache", "reqwest 0.13.5", - "semver 1.0.28", + "semver", "serde", "serde_json", "sha2 0.10.9", @@ -15411,7 +16019,7 @@ name = "windmill-api-openapi" version = "1.830.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "http 1.5.0", "indexmap 2.14.2", "itertools 0.14.0", @@ -15432,7 +16040,7 @@ dependencies = [ name = "windmill-api-schedule" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "chrono-tz", "serde", @@ -15453,7 +16061,7 @@ name = "windmill-api-scripts" version = "1.830.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "chrono", "futures", "http 1.5.0", @@ -15495,7 +16103,7 @@ name = "windmill-api-settings" version = "1.830.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "chrono", @@ -15536,7 +16144,7 @@ name = "windmill-api-users" version = "1.830.0" dependencies = [ "argon2", - "axum 0.8.9", + "axum", "chrono", "http 1.5.0", "hyper 1.12.0", @@ -15559,7 +16167,7 @@ dependencies = [ name = "windmill-api-workers" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "serde", "serde_json", @@ -15573,7 +16181,7 @@ dependencies = [ name = "windmill-api-workspaces" version = "1.830.0" dependencies = [ - "axum 0.8.9", + "axum", "chrono", "constant_time_eq 0.3.1", "futures", @@ -15628,7 +16236,7 @@ name = "windmill-autoscaling" version = "1.830.0" dependencies = [ "anyhow", - "axum 0.8.9", + "axum", "k8s-openapi", "kube", "serde", @@ -15658,7 +16266,7 @@ dependencies = [ "aws-sdk-secretsmanager", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.8.9", + "axum", "backon", "base64 0.22.1", "bitflags 2.13.2", @@ -15712,18 +16320,18 @@ dependencies = [ "reqwest-middleware", "reqwest-retry", "rsa", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-native-certs 0.8.4", "ryu-js", "schemars 0.8.22", - "semver 1.0.28", + "semver", "serde", "serde_json", "serde_yml", "sha1", "sha2 0.10.9", "size", - "spki", + "spki 0.7.3", "sqlx", "strum", "strum_macros", @@ -15880,7 +16488,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "backon", "base64 0.22.1", "chrono", @@ -15912,7 +16520,7 @@ dependencies = [ "anyhow", "arc-swap", "async-oauth2", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "hex", @@ -15941,7 +16549,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.8.9", + "axum", "bytes", "chrono", "datafusion", @@ -16258,7 +16866,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.8.9", + "axum", "backon", "chrono", "chrono-tz", @@ -16298,18 +16906,14 @@ dependencies = [ "anyhow", "const_format", "deno_ast", - "deno_console", "deno_core", "deno_crypto", - "deno_error 0.6.1", + "deno_error", "deno_fetch", - "deno_fs", - "deno_io", "deno_net", "deno_permissions", "deno_telemetry", "deno_tls", - "deno_url", "deno_web", "deno_webidl", "futures", @@ -16318,13 +16922,15 @@ dependencies = [ "rcgen", "regex", "reqwest 0.13.5", - "rustls 0.23.35", + "rustls 0.23.40", "serde", "serde_json", "sqlx", + "sys_traits", "tokio", "tracing", "uuid", + "v8", "winapi", "windmill-common", "windmill-parser-ts", @@ -16348,7 +16954,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-recursion", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "dashmap", @@ -16383,7 +16989,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "chrono", "futures", "serde", @@ -16407,7 +17013,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "chrono", "http 1.5.0", "hyper 1.12.0", @@ -16440,7 +17046,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "futures", "itertools 0.14.0", @@ -16467,7 +17073,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "chrono", @@ -16500,7 +17106,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "lazy_static", "regex", @@ -16520,7 +17126,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "chrono", @@ -16554,7 +17160,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -16591,7 +17197,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "itertools 0.14.0", "rdkafka", @@ -16614,7 +17220,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "bytes", "itertools 0.14.0", @@ -16639,7 +17245,7 @@ dependencies = [ "anyhow", "async-nats", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "itertools 0.14.0", "nkeys", @@ -16662,7 +17268,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "byteorder", "bytes", "chrono", @@ -16702,7 +17308,7 @@ dependencies = [ "aws-sdk-sqs", "aws-sdk-sts", "aws-smithy-types", - "axum 0.8.9", + "axum", "backon", "chrono", "itertools 0.14.0", @@ -16725,7 +17331,7 @@ version = "1.830.0" dependencies = [ "anyhow", "async-trait", - "axum 0.8.9", + "axum", "base64 0.22.1", "futures", "http 1.5.0", @@ -16772,7 +17378,7 @@ dependencies = [ "async-recursion", "async-stream", "async-trait", - "axum 0.8.9", + "axum", "backon", "base64 0.22.1", "bit-vec 0.6.3", @@ -16817,7 +17423,7 @@ dependencies = [ "postgres-protocol", "process-wrap", "prometheus", - "prost", + "prost 0.13.5", "quick_cache", "rand 0.9.5", "rcgen", @@ -16826,7 +17432,7 @@ dependencies = [ "reqwest-middleware", "rsa", "rust_decimal", - "rustls 0.23.35", + "rustls 0.23.40", "rustls-native-certs 0.8.4", "rustls-pemfile 2.2.0", "serde", @@ -17480,8 +18086,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "x509-parser" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7069fba5b66b9193bd2c5d3d4ff12b839118f6bcbef5328efafafb5395cf63da" +dependencies = [ + "asn1-rs 0.5.2", + "data-encoding", + "der-parser 8.2.0", + "lazy_static", + "nom", + "oid-registry 0.6.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", ] [[package]] @@ -17711,6 +18334,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zoneinfo64" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed6eb2607e906160c457fd573e9297e65029669906b9ac8fb1b5cd5e055f0705" +dependencies = [ + "calendrical_calculations", + "icu_locale_core", + "potential_utf", + "resb", + "serde", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f50a858d52..e91ad01795 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -451,7 +451,7 @@ headers = "^0" hyper = { version = "^1", features = ["full"] } hyper-tls = "^0.6" hyper-util = { version = "^0.1", features = ["client-legacy", "http1", "tokio"] } -tokio = { version = "=1.46.1", features = ["full", "tracing", "time"] } +tokio = { version = "=1.47.1", features = ["full", "tracing", "time"] } tokio-stream = { version = "0.1.17" } tower = "^0" tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] } @@ -519,33 +519,29 @@ aws-sdk-rds = "^1" async-trait = "0.1.88" -v8 = "=137.1.0" # Exact version NOTE: Do not forget to update version and hash in flake.nix -# deno_* pin set: deno v2.4.0 base, with deno_ast force-overridden to =0.51.0. -# Rationale: deno_ast 0.51.0 is the first version pulling swc_common =14.0.4, -# the first swc_common patch that dropped `pub use serde::__private as serde;` -# (the line that capped our workspace serde pin at =1.0.220). v2.4.0's other -# pins keep deno_tls at 0.196.0 which uses permissive `rustls ^0.23.11`, -# compatible with aws-sdk-bedrockruntime's `^0.23.31` requirement. deno_tls -# 0.198+ tightened that to exact `=0.23.28`, which would have made any -# meaningful deno bump resolver-impossible against aws-sdk. -deno_fetch = "0.233.0" -deno_tls = "0.196.0" -deno_console = "0.209.0" -deno_url = "0.209.0" -deno_webidl = "0.209.0" -deno_web = "0.240.0" -# Sibling release of the pinned deno_core 0.352 / deno_web 0.240 stack -# (its deps are deno_core ^0.352, deno_web ^0.240, deno_error =0.6.1). A newer -# deno_crypto would force bumping the whole deno stack. -deno_crypto = "0.223.0" -deno_io = "0.119.0" -deno_fs = "0.119.0" -deno_net = "0.201.0" -deno_core = "0.352.0" +# deno_* pin set: the crates deno published together on 2026-09-16 (deno_core +# 0.412). +# V8 is pinned exactly because flake.nix fetches the prebuilt archive of that +# version: update its version and hash there with any bump. No default features, +# so the pin leaves the feature set deno_core picks untouched. +v8 = { version = "=150.4.0", default-features = false } +# These release as a matched set: each pins its siblings, deno_error exactly, +# and deno_tls pins rustls exactly (=0.23.40, the workspace `rustls` pin below +# must equal it). A bump moves all of them at once. +# deno_ast is independent of that set and stays at 0.51.0: a newer one pulls +# swc_common 17, which would force bumping every swc_* pin the parsers use. +deno_fetch = "0.283.0" +deno_tls = "0.246.0" +deno_webidl = "0.259.0" +deno_web = "0.290.0" +deno_crypto = "0.273.0" +deno_net = "0.251.0" +deno_core = "0.412.0" deno_ast = { version = "=0.51.0", features = ["transpiling"] } -deno_permissions = "0.68.0" -deno_telemetry = "0.31.0" -deno_error = "=0.6.1" +deno_permissions = "0.118.0" +deno_telemetry = "0.81.0" +deno_error = "=0.7.1" +sys_traits = { version = "0.1.28", features = ["real", "libc", "winapi"] } rustls-pemfile = "2.2.0" # only used with special deno_core_mac feature to prevent ffi issue on macos, requires libffi to be installed @@ -682,7 +678,7 @@ aws-sdk-sqs = "^1.89.0" aws-sdk-sts = "^1.91.0" aws-sdk-sso = "=1.77.0" aws-sdk-ssooidc = "=1.78.0" -rustls = "=0.23.35" +rustls = "=0.23.40" async-once-cell = "0.5.4" size = "0.5.0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 15e23c383d..b2d35dc125 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -5d7726c4979070ee8db4fb9e8014152872a641ba +599eae0034b34c5e0d3d42df37cf18bbe01f9b49 diff --git a/backend/windmill-runtime-nativets/Cargo.toml b/backend/windmill-runtime-nativets/Cargo.toml index d0b4e97f19..22da97ab1d 100644 --- a/backend/windmill-runtime-nativets/Cargo.toml +++ b/backend/windmill-runtime-nativets/Cargo.toml @@ -22,16 +22,15 @@ deno_webidl.workspace = true deno_web.workspace = true deno_crypto.workspace = true deno_net.workspace = true -deno_console.workspace = true -deno_url.workspace = true deno_core.workspace = true deno_ast.workspace = true deno_tls.workspace = true deno_permissions.workspace = true -deno_io.workspace = true -deno_fs.workspace = true deno_telemetry.workspace = true deno_error.workspace = true +sys_traits.workspace = true +# Not used directly: depending on it is what makes the workspace pin bind. +v8.workspace = true winapi.workspace = true itertools.workspace = true @@ -58,14 +57,8 @@ deno_webidl.workspace = true deno_web.workspace = true deno_crypto.workspace = true deno_net.workspace = true -deno_console.workspace = true -deno_url.workspace = true deno_core.workspace = true deno_ast.workspace = true -deno_tls.workspace = true -deno_permissions.workspace = true -deno_io.workspace = true -deno_fs.workspace = true deno_telemetry.workspace = true deno_error.workspace = true winapi.workspace = true diff --git a/backend/windmill-runtime-nativets/build.rs b/backend/windmill-runtime-nativets/build.rs index 29a6dbf81b..fab6cbb1ed 100644 --- a/backend/windmill-runtime-nativets/build.rs +++ b/backend/windmill-runtime-nativets/build.rs @@ -1,107 +1,11 @@ use deno_ast::{MediaType, ParseParams}; -use deno_core::{ModuleCodeString, ModuleName, SourceMapData}; +use deno_core::{Extension, ModuleCodeString, ModuleName, SourceMapData}; use deno_error::JsErrorBox; -use deno_fetch::FetchPermissions; -use deno_net::NetPermissions; -use deno_web::{BlobStore, TimersPermission}; -use std::borrow::Cow; +use std::collections::HashSet; use std::env; +use std::fmt::Write as _; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::Arc; - -pub struct PermissionsContainer; - -impl FetchPermissions for PermissionsContainer { - #[inline(always)] - fn check_net_url( - &mut self, - _url: &deno_core::url::Url, - _api_name: &str, - ) -> Result<(), deno_permissions::PermissionCheckError> { - unreachable!("snapshotting") - } - - #[inline(always)] - fn check_read<'a>( - &mut self, - _path: Cow<'a, Path>, - _api_name: &str, - _get_path: &'a dyn deno_fs::GetPath, - ) -> Result, deno_io::fs::FsError> { - unreachable!("snapshotting") - } - - #[inline(always)] - fn check_write<'a>( - &mut self, - _path: Cow<'a, Path>, - _api_name: &str, - _get_path: &'a dyn deno_fs::GetPath, - ) -> Result, deno_io::fs::FsError> { - unreachable!("snapshotting") - } - - #[inline(always)] - fn check_net_vsock( - &mut self, - _cid: u32, - _port: u32, - _api_name: &str, - ) -> Result<(), deno_permissions::PermissionCheckError> { - unreachable!("snapshotting") - } -} - -impl TimersPermission for PermissionsContainer { - #[inline(always)] - fn allow_hrtime(&mut self) -> bool { - true - } -} - -impl NetPermissions for PermissionsContainer { - fn check_read( - &mut self, - _p: &str, - _api_name: &str, - ) -> Result { - unreachable!("snapshotting") - } - - fn check_write( - &mut self, - _p: &str, - _api_name: &str, - ) -> Result { - unreachable!("snapshotting") - } - - fn check_net>( - &mut self, - _host: &(T, Option), - _api_name: &str, - ) -> Result<(), deno_permissions::PermissionCheckError> { - unreachable!("snapshotting") - } - - fn check_write_path<'a>( - &mut self, - _p: Cow<'a, Path>, - _api_name: &str, - ) -> Result, deno_permissions::PermissionCheckError> { - unreachable!("snapshotting") - } - - fn check_vsock( - &mut self, - _cid: u32, - _port: u32, - _api_name: &str, - ) -> Result<(), deno_permissions::PermissionCheckError> { - unreachable!("snapshotting") - } -} deno_core::extension!( fetch, @@ -109,38 +13,29 @@ deno_core::extension!( esm = ["src/runtime.js"], ); -// `extension_transpiler` callback for `deno_core::snapshot::create_snapshot`. +// `extension_transpiler` callback for `deno_core::snapshot::create_snapshot`, +// also applied to the residual lazy sources below. // -// Specialized to our snapshot's inputs. Of the eight deno_* extensions -// we register via `init()`, seven ship pre-built `.js` files -// in their `esm` lists (webidl/url/console/web/crypto/fetch/net) — only -// `deno_telemetry`'s `extension!` macro lists `.ts` files -// (`telemetry.ts`, `util.ts`), so the TypeScript branch is needed -// solely for that crate. Our local `fetch` extension contributes -// `src/runtime.js` (pure JS). No `node:` imports happen at snapshot -// build time, no `.mjs`, no user-supplied modules. So: +// Specialized to our inputs: the deno_* extensions ship `.js`, except +// `deno_telemetry` whose lazy scripts are `.ts`. Our local `fetch` extension +// contributes `src/runtime.js`. No `node:` imports, no `.mjs`, no +// user-supplied modules. So: // - `.js` → pass through. // - `.ts` → transpile via deno_ast (deno_telemetry only). // - anything else → build bug (deno shipping an unexpected file type // or us mislabelling one), panic loudly rather than emit a broken // snapshot. // -// No source maps: the snapshot is a binary blob the runtime loads — source -// maps would never be consumed. +// No source maps: nothing consumes them. // -// The signature still returns `Result<_, JsErrorBox>` because that's what -// `extension_transpiler` expects, but we never construct one — parse and -// transpile failures are build-time bugs in deno's own .ts internals (or -// in our runtime.js, if we ever change its extension), so they panic. +// The signature returns `Result<_, JsErrorBox>` because that's what +// `extension_transpiler` expects, but we never construct one: parse and +// transpile failures are build-time bugs, so they panic. // -// This replaces a call to `deno_runtime::transpile::maybe_transpile_source` -// from `deno_runtime 0.198.0`. The original is more general (handles -// `node:` modules, `.mjs`, emits source maps in debug builds, plumbs -// errors via `JsErrorBox`); none of that surface is reachable in our -// build. Dropping the `deno_runtime` dep eliminates a -// `deno_cache → rusqlite → libsqlite3-sys 0.35` transitive chain that -// collides with sqlx-sqlite's `libsqlite3-sys 0.30` (cargo's -// `links = "sqlite3"` rule). +// `deno_runtime::transpile::maybe_transpile_source` is the general version; +// depending on `deno_runtime` would pull a +// `deno_cache → rusqlite → libsqlite3-sys` chain that collides with +// sqlx-sqlite's `libsqlite3-sys` (cargo's `links = "sqlite3"` rule). fn maybe_transpile_source( name: ModuleName, source: ModuleCodeString, @@ -154,7 +49,7 @@ fn maybe_transpile_source( let parsed = deno_ast::parse_module(ParseParams { specifier: deno_core::url::Url::parse(&name).unwrap(), - text: source.into(), + text: source.as_str().into(), media_type, capture_tokens: false, scope_analysis: false, @@ -169,7 +64,13 @@ fn maybe_transpile_source( ..Default::default() }, &deno_ast::TranspileModuleOptions::default(), - &deno_ast::EmitOptions::default(), + // The default appends an inline source map comment, which is a + // syntax error where deno_core evaluates a lazy script as an + // expression. + &deno_ast::EmitOptions { + source_map: deno_ast::SourceMapOption::None, + ..Default::default() + }, ) .unwrap_or_else(|e| panic!("snapshot transpile: emit failed for {name}: {e}")) .into_source(); @@ -177,27 +78,66 @@ fn maybe_transpile_source( Ok((transpiled.text.into(), None)) } +/// One `(specifier, source)` table of the extension scripts the snapshot did +/// not evaluate. The snapshot only carries scripts that ran while it was built, +/// so a script first loaded in a live isolate has to come from here. +/// +/// deno_core binary-searches the table and reads each source as ASCII without +/// checking, so it must be sorted by specifier, free of duplicates, and ASCII. +fn residual_table( + name: &str, + files: impl Iterator, + consumed: &HashSet, +) -> String { + let mut files: Vec<_> = files.filter(|f| !consumed.contains(f.specifier)).collect(); + files.sort_by_key(|f| f.specifier); + files.dedup_by_key(|f| f.specifier); + + let mut out = format!("pub static {name}: &[(&str, &str)] = &[\n"); + for file in files { + let source = file + .load() + .unwrap_or_else(|e| panic!("could not load {}: {e}", file.specifier)); + let (code, _) = + maybe_transpile_source(ModuleName::from_static(file.specifier), source).unwrap(); + assert!( + code.as_str().is_ascii(), + "{} is not ASCII after transpiling", + file.specifier + ); + writeln!(out, " ({:?}, {:?}),", file.specifier, code.as_str()).unwrap(); + } + out.push_str("];\n"); + out +} + fn main() { println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap()); println!("cargo:rustc-env=PROFILE={}", env::var("PROFILE").unwrap()); - let exts = vec![ - deno_telemetry::deno_telemetry::init(), - deno_webidl::deno_webidl::init(), - deno_url::deno_url::init(), - deno_console::deno_console::init(), - deno_web::deno_web::init::(Arc::new(BlobStore::default()), None), - deno_crypto::deno_crypto::init(None), - deno_fetch::deno_fetch::init::(Default::default()), - deno_net::deno_net::init::(None, None), + // Same list and order as `create_nativets_runtime`, which supplies their + // state through `lazy_init_extensions`. + let exts: Vec = vec![ + deno_telemetry::deno_telemetry::lazy_init(), + deno_webidl::deno_webidl::lazy_init(), + deno_web::deno_web::lazy_init(), + deno_crypto::deno_crypto::lazy_init(), + deno_fetch::deno_fetch::lazy_init(), + deno_net::deno_net::lazy_init(), fetch::init(), ]; - // Build the file path to the snapshot. + let lazy_js: Vec<_> = exts + .iter() + .flat_map(|e| e.lazy_loaded_js_files.iter().cloned()) + .collect(); + let lazy_esm: Vec<_> = exts + .iter() + .flat_map(|e| e.lazy_loaded_esm_files.iter().cloned()) + .collect(); + let o = PathBuf::from(env::var_os("OUT_DIR").unwrap()); - let snapshot_path = o.join("FETCH_SNAPSHOT.bin"); - // Create the snapshot. let output = deno_core::snapshot::create_snapshot( deno_core::snapshot::CreateSnapshotOptions { cargo_manifest_dir: env!("CARGO_MANIFEST_DIR"), @@ -213,9 +153,17 @@ fn main() { ) .unwrap(); - let mut file = std::fs::File::create(snapshot_path).unwrap(); + let mut file = std::fs::File::create(o.join("FETCH_SNAPSHOT.bin")).unwrap(); file.write_all(&output.output).unwrap(); + let consumed: HashSet = output.consumed_lazy_specifiers.into_iter().collect(); + let residual = format!( + "{}{}", + residual_table("RESIDUAL_LAZY_JS", lazy_js.into_iter(), &consumed), + residual_table("RESIDUAL_LAZY_ESM", lazy_esm.into_iter(), &consumed), + ); + std::fs::write(o.join("residual_lazy_sources.rs"), residual).unwrap(); + for path in output.files_loaded_during_snapshot { println!("cargo:rerun-if-changed={}", path.display()); } diff --git a/backend/windmill-runtime-nativets/src/lib.rs b/backend/windmill-runtime-nativets/src/lib.rs index 75aacbcc64..750cdaa609 100644 --- a/backend/windmill-runtime-nativets/src/lib.rs +++ b/backend/windmill-runtime-nativets/src/lib.rs @@ -22,9 +22,7 @@ mod smoke_tests; mod cert_tests; use std::{ - borrow::Cow, cell::RefCell, - path::PathBuf, rc::Rc, sync::{Arc, LazyLock, Mutex}, }; @@ -32,17 +30,55 @@ use std::{ // Re-export deno_telemetry for use by windmill-worker's otel proxy pub use deno_telemetry; +/// The real environment and filesystem, except that the OTLP protocol always +/// reads as HTTP. +/// +/// The caller points `OTEL_EXPORTER_OTLP_ENDPOINT` at its own HTTP collector +/// only while [`init_telemetry`] runs, and the instance's own exporter shares +/// `OTEL_EXPORTER_OTLP_PROTOCOL`. Honouring `grpc` there would build an exporter +/// that reads the endpoint again on every export, and `console` one that never +/// reads it, so either would send job telemetry past that collector. +struct HttpOtlpSys; + +impl sys_traits::BaseEnvVar for HttpOtlpSys { + fn base_env_var_os(&self, key: &std::ffi::OsStr) -> Option { + if key == "OTEL_EXPORTER_OTLP_PROTOCOL" { + return Some("http/protobuf".into()); + } + sys_traits::impls::RealSys.base_env_var_os(key) + } +} + +impl sys_traits::BaseFsRead for HttpOtlpSys { + fn base_fs_read( + &self, + path: &std::path::Path, + ) -> std::io::Result> { + sys_traits::impls::RealSys.base_fs_read(path) + } +} + +/// `deno_telemetry::init` for the isolates of this process. +pub fn init_telemetry( + rt_config: deno_telemetry::OtelRuntimeConfig, + config: deno_telemetry::OtelConfig, +) -> anyhow::Result<()> { + deno_telemetry::init(&HttpOtlpSys, rt_config, config) +} + use deno_ast::ParseParams; use deno_core::{ + error::CoreErrorKind, op2, serde_v8, url, v8::{self, IsolateHandle}, Extension, JsRuntime, OpState, PollEventLoopOptions, RuntimeOptions, }; use deno_error::JsErrorBox; -use deno_fetch::FetchPermissions; -use deno_net::NetPermissions; +use deno_permissions::{ + Permissions, PermissionsContainer, PermissionsOptions, RuntimePermissionDescriptorParser, +}; use deno_tls::{rustls::pki_types::CertificateDer, rustls::RootCertStore, RootCertStoreProvider}; -use deno_web::{BlobStore, TimersPermission}; +use deno_web::{BlobStore, InMemoryBroadcastChannel}; use itertools::Itertools; use lazy_static::lazy_static; use regex::Regex; @@ -57,156 +93,55 @@ use windmill_common::worker::{write_file, Connection, WINDMILL_DIR}; // ── Snapshot-matched extensions ────────────────────────────────────── // -// `deno_core` 0.352 validates that the snapshot's extension list is a -// *prefix* of the runtime's extension list (snapshot does not need an -// exact match — runtime is allowed to add extensions at the tail, but -// must not reorder or omit any that the snapshot baked in). +// The snapshot (build.rs) is built from the deno_* extensions in their +// `lazy_init()` form followed by this local `fetch` ext. deno_core requires the +// runtime's extension list to start with that same list in that same order; it +// may append more, which is how the windmill `ext` carrying our own ops gets in. // -// Our snapshot (in build.rs) is the same eight deno_* extensions ending -// with this local `fetch` ext. The runtime adds one extra entry at the -// end — the windmill `ext` carrying our own ops — which is fine because -// it's after the snapshot prefix. -// -// This local `fetch` extension declaration must be present in both -// build.rs and lib.rs so the type passes through the `init()` macro. -// The ESM is already in the snapshot, so this `init()` call at runtime -// is a no-op for esm — the registration just records the ext. +// This declaration must exist in both build.rs and lib.rs. The ESM is already +// in the snapshot, so `init()` at runtime only records the extension. deno_core::extension!( fetch, esm_entry_point = "ext:fetch/src/runtime.js", esm = ["src/runtime.js"], ); -// ── Permission container ───────────────────────────────────────────── +include!(concat!(env!("OUT_DIR"), "/residual_lazy_sources.rs")); -/// `no_network` is what `//no_network` promises: no outbound traffic of any kind, -/// the Windmill API included (the client module reaches it through `fetch`). -/// Enforced here and by disabling the deno_net ops, never in JS: user code can -/// call `Deno.core.ops.*` directly and skip any JS-level guard. -pub struct PermissionsContainer { - pub no_network: bool, -} - -const NO_NETWORK_ANNOTATION: &str = "no_network"; +// ── Permissions ────────────────────────────────────────────────────── /// Every op deno_net registers, see `create_nativets_runtime`. Read from the /// extension itself so an upgrade that adds an op cannot leave it enabled. static NO_NETWORK_DISABLED_OPS: LazyLock> = LazyLock::new(|| { - deno_net::deno_net::init::(None, None) + deno_net::deno_net::lazy_init() .ops .iter() .map(|op| op.name) .collect() }); -impl PermissionsContainer { - fn deny_net(&self, target: &str) -> Result<(), deno_permissions::PermissionCheckError> { - if self.no_network { - Err(deno_permissions::PermissionDeniedError::Fatal { - access: format!( - "net access to {target} (the script is annotated //{NO_NETWORK_ANNOTATION})" - ), - } - .into()) - } else { - Ok(()) - } - } -} - -impl FetchPermissions for PermissionsContainer { - #[inline(always)] - fn check_net_url( - &mut self, - url: &deno_core::url::Url, - _api_name: &str, - ) -> Result<(), deno_permissions::PermissionCheckError> { - self.deny_net(&format!("\"{}\"", url.host_str().unwrap_or_default())) - } - - #[inline(always)] - fn check_read<'a>( - &mut self, - path: Cow<'a, std::path::Path>, - _api_name: &str, - _get_path: &'a dyn deno_fs::GetPath, - ) -> Result, deno_io::fs::FsError> { - Ok(deno_fs::CheckedPath::Unresolved(path)) - } - - #[inline(always)] - fn check_write<'a>( - &mut self, - path: Cow<'a, std::path::Path>, - _api_name: &str, - _get_path: &'a dyn deno_fs::GetPath, - ) -> Result, deno_io::fs::FsError> { - Ok(deno_fs::CheckedPath::Unresolved(path)) - } - - #[inline(always)] - fn check_net_vsock( - &mut self, - cid: u32, - port: u32, - _api_name: &str, - ) -> Result<(), deno_permissions::PermissionCheckError> { - self.deny_net(&format!("vsock {cid}:{port}")) - } -} - -impl TimersPermission for PermissionsContainer { - #[inline(always)] - fn allow_hrtime(&mut self) -> bool { - true - } -} - -impl NetPermissions for PermissionsContainer { - fn check_read( - &mut self, - p: &str, - _api_name: &str, - ) -> Result { - self.deny_net(&format!("unix socket \"{p}\""))?; - Ok(PathBuf::from(p)) - } - - fn check_write( - &mut self, - p: &str, - _api_name: &str, - ) -> Result { - self.deny_net(&format!("unix socket \"{p}\""))?; - Ok(PathBuf::from(p)) - } - - fn check_net>( - &mut self, - host: &(T, Option), - _api_name: &str, - ) -> Result<(), deno_permissions::PermissionCheckError> { - self.deny_net(&format!("\"{}\"", host.0.as_ref())) - } - - fn check_write_path<'a>( - &mut self, - p: Cow<'a, std::path::Path>, - _api_name: &str, - ) -> Result, deno_permissions::PermissionCheckError> { - self.deny_net(&format!("unix socket \"{}\"", p.display()))?; - Ok(p) - } - - fn check_vsock( - &mut self, - cid: u32, - port: u32, - _api_name: &str, - ) -> Result<(), deno_permissions::PermissionCheckError> { - self.deny_net(&format!("vsock {cid}:{port}")) - } +/// What an isolate may do: reach the network, unless `no_network`, and nothing +/// else (no filesystem, environment, subprocess or FFI). Nothing prompts. +/// +/// `no_network` is what `//no_network` promises: no outbound traffic of any +/// kind, the Windmill API included (the client module reaches it through +/// `fetch`). Enforced here and by disabling the deno_net ops, never in JS: user +/// code can call `Deno.core.ops.*` directly and skip any JS-level guard. +fn isolate_permissions(no_network: bool) -> anyhow::Result { + let parser = Arc::new(RuntimePermissionDescriptorParser::new( + sys_traits::impls::RealSys, + )); + // An empty list means "all hosts", for a grant and for a denial alike. + let options = PermissionsOptions { + allow_net: (!no_network).then(Vec::new), + deny_net: no_network.then(Vec::new), + prompt: false, + ..Default::default() + }; + let permissions = Permissions::from_options(parser.as_ref(), &options) + .map_err(|e| anyhow::anyhow!("invalid isolate permissions: {e}"))?; + Ok(PermissionsContainer::new(parser, permissions)) } // ── Types ──────────────────────────────────────────────────────────── @@ -228,7 +163,7 @@ pub struct NativeAnnotation { /// script; `None` leaves the default in force. pub fetch_response_timeout_secs: Option, /// `//no_network`: the isolate may open no connection at all, see - /// [`PermissionsContainer`]. + /// [`isolate_permissions`]. pub no_network: bool, } @@ -413,13 +348,10 @@ pub fn setup_deno_runtime() -> anyhow::Result<()> { // deno_fetch requires a TLS provider; install ring as default (idempotent). let _ = rustls::crypto::ring::default_provider().install_default(); - let unrecognized_v8_flags = deno_core::v8_set_flags(vec![ - "--stack-size=1024".to_string(), - "--no-harmony-import-assertions".to_string(), - ]) - .into_iter() - .skip(1) - .collect::>(); + let unrecognized_v8_flags = deno_core::v8_set_flags(vec!["--stack-size=1024".to_string()]) + .into_iter() + .skip(1) + .collect::>(); if !unrecognized_v8_flags.is_empty() { init_err = Some(format!( @@ -435,7 +367,7 @@ pub fn setup_deno_runtime() -> anyhow::Result<()> { // SIGSEGV in WasmCodePointerTable::AllocateUninitializedEntry() on x86_64 Linux. // See: https://github.com/denoland/deno_core/issues/952 let platform = deno_core::v8::new_unprotected_default_platform(0, false).make_shared(); - deno_core::JsRuntime::init_platform(Some(platform), false); + deno_core::JsRuntime::init_platform(Some(platform)); }); if let Some(msg) = init_err { @@ -666,17 +598,15 @@ pub(crate) fn create_nativets_runtime( ..Default::default() }; + // Same list and order as the snapshot (build.rs); their state follows in + // `lazy_init_extensions` below, which must match it too. let exts: Vec = vec![ - deno_telemetry::deno_telemetry::init(), - deno_webidl::deno_webidl::init(), - deno_url::deno_url::init(), - deno_console::deno_console::init(), - deno_web::deno_web::init::(Arc::new(BlobStore::default()), None), - // Registered after deno_web to keep the snapshot (build.rs) a prefix of - // the runtime extension list; deno_crypto declares deps = [deno_webidl, deno_web]. - deno_crypto::deno_crypto::init(None), - deno_fetch::deno_fetch::init::(fetch_options), - deno_net::deno_net::init::(None, None), + deno_telemetry::deno_telemetry::lazy_init(), + deno_webidl::deno_webidl::lazy_init(), + deno_web::deno_web::lazy_init(), + deno_crypto::deno_crypto::lazy_init(), + deno_fetch::deno_fetch::lazy_init(), + deno_net::deno_net::lazy_init(), fetch::init(), ext, ]; @@ -688,6 +618,8 @@ pub(crate) fn create_nativets_runtime( deno_core::v8::CreateParams::default().heap_limits(0, 1024 * 1024 * 128), ), startup_snapshot: Some(RUNTIME_SNAPSHOT), + residual_lazy_js_sources: RESIDUAL_LAZY_JS, + residual_lazy_esm_sources: RESIDUAL_LAZY_ESM, module_loader: Some(Rc::new(deno_core::FsModuleLoader)), extension_transpiler: None, ..Default::default() @@ -714,12 +646,28 @@ pub(crate) fn create_nativets_runtime( y * 2 }); + js_runtime + .lazy_init_extensions(vec![ + deno_telemetry::deno_telemetry::args(), + deno_webidl::deno_webidl::args(), + deno_web::deno_web::args( + Arc::new(BlobStore::default()), + None, + false, + InMemoryBroadcastChannel::default(), + ), + deno_crypto::deno_crypto::args(None), + deno_fetch::deno_fetch::args(fetch_options), + deno_net::deno_net::args(None, None), + ]) + .map_err(windmill_common::error::to_anyhow)?; + let (log_sender, log_receiver) = mpsc::unbounded_channel::(); { let op_state = js_runtime.op_state(); let mut op_state = op_state.borrow_mut(); - op_state.put(PermissionsContainer { no_network }); + op_state.put(isolate_permissions(no_network)?); op_state.put(MainArgs { args: initial_args }); op_state.put(LogString { s: log_sender }); } @@ -731,7 +679,7 @@ pub(crate) fn create_nativets_runtime( .execute_script( "", format!( - "globalThis.__wmInitPerIsolate({{ fetchResponseTimeoutMs: {fetch_response_timeout_ms} }})" + "globalThis.__wmInitPerIsolate({{ fetchResponseTimeoutMs: {fetch_response_timeout_ms}, noNetwork: {no_network} }})" ), ) .map_err(windmill_common::error::to_anyhow)?; @@ -777,7 +725,7 @@ pub(crate) fn extract_global_string( js_runtime: &mut JsRuntime, global: v8::Global, ) -> Result, String> { - let scope = &mut js_runtime.handle_scope(); + deno_core::scope!(scope, js_runtime); let local = v8::Local::new(scope, global); match serde_v8::from_v8::>(scope, local) { Ok(s) => Ok(unsafe_raw(s.unwrap_or_else(|| "null".to_string()))), @@ -1169,7 +1117,10 @@ import("file:///eval.ts").then((module) => module.{main_fn}(...args)) Ok(global) => { extract_global_string(js_runtime, global).map_err(|e| ExecuteError::Script(e)) } - Err(deno_core::error::CoreError::Js(e)) => { + Err(e) if matches!(e.as_kind(), CoreErrorKind::Js(_)) => { + let CoreErrorKind::Js(e) = e.into_kind() else { + unreachable!("matched as a JS error above") + }; let source = e.frames.first().and_then(|f| { f.file_name .as_ref() diff --git a/backend/windmill-runtime-nativets/src/runtime.js b/backend/windmill-runtime-nativets/src/runtime.js index 88e10f765a..86d20dd022 100644 --- a/backend/windmill-runtime-nativets/src/runtime.js +++ b/backend/windmill-runtime-nativets/src/runtime.js @@ -1,39 +1,42 @@ -import * as abortSignal from "ext:deno_web/03_abort_signal.js"; -import * as domException from "ext:deno_web/01_dom_exception.js"; -import * as base64 from "ext:deno_web/05_base64.js"; -import * as console from "ext:deno_console/01_console.js"; -import * as encoding from "ext:deno_web/08_text_encoding.js"; -import * as event from "ext:deno_web/02_event.js"; -import * as fetch from "ext:deno_fetch/26_fetch.js"; -import * as file from "ext:deno_web/09_file.js"; -import * as fileReader from "ext:deno_web/10_filereader.js"; -import * as formData from "ext:deno_fetch/21_formdata.js"; -import * as headers from "ext:deno_fetch/20_headers.js"; -import * as streams from "ext:deno_web/06_streams.js"; -import * as timers from "ext:deno_web/02_timers.js"; -import * as url from "ext:deno_url/00_url.js"; -import * as net from "ext:deno_net/01_net.js"; -import * as tls from "ext:deno_net/02_tls.js"; -import * as urlPattern from "ext:deno_url/01_urlpattern.js"; -import * as webidl from "ext:deno_webidl/00_webidl.js"; -import * as crypto from "ext:deno_crypto/00_crypto.js"; -import * as response from "ext:deno_fetch/23_response.js"; -import * as request from "ext:deno_fetch/23_request.js"; -import "ext:deno_web/02_structured_clone.js"; -import * as globalInterfaces from "ext:deno_web/04_global_interfaces.js"; -// Namespace imports (not side-effect-only) so their constructors are reachable -// for the globalThis wiring below. The module bodies still execute on -// evaluation, so their side effects apply. -import * as messagePort from "ext:deno_web/13_message_port.js"; -import * as compression from "ext:deno_web/14_compression.js"; -import * as performance from "ext:deno_web/15_performance.js"; -import "ext:deno_web/16_image_data.js"; -import "ext:deno_fetch/27_eventsource.js"; +import { core, internals } from "ext:core/mod.js"; + +// deno_crypto brands CryptoKeys with this symbol and reads it from `internals` +// when it evaluates; in Deno a deno_node module sets it, which is not loaded here. +internals.kKeyObject ??= Symbol("kKeyObject"); + +// Every script loaded here runs at snapshot-build time and is baked into the +// snapshot. One that is first loaded at run time comes from the residual table +// build.rs emits instead. +const abortSignal = core.loadExtScript("ext:deno_web/03_abort_signal.js"); +const domException = core.loadExtScript("ext:deno_web/01_dom_exception.js"); +const base64 = core.loadExtScript("ext:deno_web/05_base64.js"); +const console = core.loadExtScript("ext:deno_web/01_console.js"); +const encoding = core.loadExtScript("ext:deno_web/08_text_encoding.js"); +const event = core.loadExtScript("ext:deno_web/02_event.js"); +const fetch = core.loadExtScript("ext:deno_fetch/26_fetch.js"); +const file = core.loadExtScript("ext:deno_web/09_file.js"); +const fileReader = core.loadExtScript("ext:deno_web/10_filereader.js"); +const formData = core.loadExtScript("ext:deno_fetch/21_formdata.js"); +const headers = core.loadExtScript("ext:deno_fetch/20_headers.js"); +const streams = core.loadExtScript("ext:deno_web/06_streams.js"); +const timers = core.loadExtScript("ext:deno_web/02_timers.js"); +const url = core.loadExtScript("ext:deno_web/00_url.js"); +const urlPattern = core.loadExtScript("ext:deno_web/01_urlpattern.js"); +const webidl = core.loadExtScript("ext:deno_webidl/00_webidl.js"); +const crypto = core.loadExtScript("ext:deno_crypto/00_crypto.js"); +const response = core.loadExtScript("ext:deno_fetch/23_response.js"); +const request = core.loadExtScript("ext:deno_fetch/23_request.js"); +const globalInterfaces = core.loadExtScript( + "ext:deno_web/04_global_interfaces.js", +); +const messagePort = core.loadExtScript("ext:deno_web/13_message_port.js"); +const compression = core.loadExtScript("ext:deno_web/14_compression.js"); +const performance = core.loadExtScript("ext:deno_web/15_performance.js"); // deno_fetch applies no deadline, so a peer that accepts a request and then // never answers leaves `await fetch(...)` pending until the job timeout, which // self-hosted defaults to 7 days. -const ORIGINAL_FETCH = fetch.fetch; +const DENO_FETCH = fetch.fetch; // deno_web's timers reject any `this` other than undefined/globalThis, so // `timers.setTimeout(...)` passes the module namespace and throws "Illegal @@ -50,6 +53,14 @@ const abortControllerAbort = Function.prototype.call.bind( ); const ReflectApply = Reflect.apply; +// Forwards the original argument count, like every caller below relies on. The +// extra promise hop under `no_network` costs the same-tick settling of an +// aborted fetch, which only matters where a request could have been made. +function ORIGINAL_FETCH() { + const p = ReflectApply(DENO_FETCH, undefined, arguments); + return noNetwork ? promiseThen(p, undefined, rewordNoNetworkDenial) : p; +} + // Installed per isolate by __wmInitPerIsolate; 0 disables. Only a backstop // for the impossible case of fetch running before that init. let fetchResponseTimeoutMs = 900_000; @@ -74,6 +85,21 @@ function fetchResponseTimeoutError(requestUrl, timeoutMs) { ); } +// Set per isolate for a `//no_network` script. The denial itself is the +// isolate's permissions (Rust); this only replaces deno's "run again with the +// --allow-net flag" hint, which names a flag a script author cannot pass. +let noNetwork = false; + +function rewordNoNetworkDenial(e) { + if (e?.name === "NotCapable" && typeof e.message === "string") { + e.message = e.message.replace( + ", run again with the --allow-net flag", + ": the script is annotated //no_network", + ); + } + throw e; +} + globalThis.atob = base64.atob; globalThis.btoa = base64.btoa; // Not `async`, for the same reason deno_fetch's own outer fetch isn't: WPT @@ -152,7 +178,22 @@ globalThis.console = new console.Console((msg, level) => ); globalThis.AbortController = abortSignal.AbortController; globalThis.AbortSignal = abortSignal.AbortSignal; -globalThis.crypto = crypto.crypto; +// A getter, not a value: `crypto.crypto` mints a cppgc object, and the cppgc +// heap is not attached while this module runs at snapshot-build time. +Object.defineProperty(globalThis, "crypto", { + configurable: true, + enumerable: true, + get: () => crypto.crypto, + // Assignment has to keep working, as it does on a plain property. + set(value) { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + enumerable: true, + writable: true, + value, + }); + }, +}); globalThis.Crypto = crypto.Crypto; globalThis.CryptoKey = crypto.CryptoKey; globalThis.SubtleCrypto = crypto.SubtleCrypto; @@ -233,6 +274,7 @@ globalThis.__wmInitPerIsolate = (config) => { if (config != null && typeof config.fetchResponseTimeoutMs === "number") { fetchResponseTimeoutMs = config.fetchResponseTimeoutMs; } + noNetwork = config?.noNetwork === true; // setTimeOrigin() seeds performance.timeOrigin from the isolate's wall clock; // without it timeOrigin is undefined and `timeOrigin + performance.now()` is NaN. @@ -256,13 +298,14 @@ globalThis.__wmInitPerIsolate = (config) => { }; // Expose bootstrapOtel globally so it can be called from Rust after runtime creation. -// We use dynamic import so deno_telemetry isn't loaded during snapshot creation. +// Loaded on first call so deno_telemetry isn't evaluated during snapshot creation. // Config: [tracingEnabled, metricsEnabled, consoleConfig, deterministic] // consoleConfig: 0=ignore, 1=capture, 2=replace globalThis.__bootstrapOtel = () => { - import("ext:deno_telemetry/telemetry.ts").then(({ bootstrap, enterSpan }) => { - bootstrap([1, 0, 1, 0]); - // Expose enterSpan for setting parent trace context - globalThis.__enterSpan = enterSpan; - }); + const { bootstrap, enterSpan } = core.loadExtScript( + "ext:deno_telemetry/telemetry.ts", + ); + bootstrap([1, 0, 1, 0]); + // Expose enterSpan for setting parent trace context + globalThis.__enterSpan = enterSpan; }; diff --git a/backend/windmill-runtime-nativets/src/smoke_tests.rs b/backend/windmill-runtime-nativets/src/smoke_tests.rs index f3da460227..1373a8abd4 100644 --- a/backend/windmill-runtime-nativets/src/smoke_tests.rs +++ b/backend/windmill-runtime-nativets/src/smoke_tests.rs @@ -451,6 +451,10 @@ export async function main(): Promise<{ uuid: string; nonzero: boolean; sha256: .map((b) => b.toString(16).padStart(2, "0")) .join(""); + // `crypto` is installed as an accessor; polyfill-style assignment must + // still work rather than throw for want of a setter. + (globalThis as any).crypto = globalThis.crypto; + return { uuid, nonzero, sha256 }; } "#; diff --git a/backend/windmill-runtime-nativets/tests/no_network.rs b/backend/windmill-runtime-nativets/tests/no_network.rs index 876207fc26..dc7086f22e 100644 --- a/backend/windmill-runtime-nativets/tests/no_network.rs +++ b/backend/windmill-runtime-nativets/tests/no_network.rs @@ -55,6 +55,9 @@ async function attempt(f) {{ export async function main() {{ return {{ fetch: await attempt(() => fetch("http://127.0.0.1:{port}/").then((r) => r.text())), + data_url: await attempt(() => fetch("data:text/plain,ok").then((r) => r.text())), + blob_url: await attempt(() => fetch(URL.createObjectURL(new Blob(["ok"]))).then((r) => r.text())), + raw_fetch: await attempt(() => Deno.core.ops.op_fetch("GET", "http://127.0.0.1:{port}/", [], null, false, null, null)), tcp: await attempt(() => Deno.core.ops.op_net_connect_tcp({{ hostname: "127.0.0.1", port: {port} }}, null, null)), unix: await attempt(() => Deno.core.ops.op_net_connect_unix("/var/run/docker.sock")), quic: await attempt(() => Deno.core.ops.op_quic_endpoint_create({{ hostname: "localhost", port: 0 }}, true)), @@ -76,6 +79,15 @@ async fn no_network_denies_fetch_and_raw_socket_ops() { fetch.contains("no_network"), "fetch was not denied: {fetch}" ); + // Neither opens a connection, so neither is network access. + assert_eq!(out["data_url"], "allowed", "{out}"); + assert_eq!(out["blob_url"], "allowed", "{out}"); + // The global `fetch` only words the refusal; the op under it is what refuses. + let raw_fetch = out["raw_fetch"].as_str().unwrap(); + assert!( + raw_fetch.contains("Requires net access"), + "the fetch op was not denied: {raw_fetch}" + ); for key in ["tcp", "unix", "quic"] { let msg = out[key].as_str().unwrap(); assert!( diff --git a/backend/windmill-runtime-nativets/tests/otel_e2e.rs b/backend/windmill-runtime-nativets/tests/otel_e2e.rs index 22fd18de90..d1853c461d 100644 --- a/backend/windmill-runtime-nativets/tests/otel_e2e.rs +++ b/backend/windmill-runtime-nativets/tests/otel_e2e.rs @@ -71,7 +71,7 @@ async fn spawn_capturing_http(captured: Arc>>>) -> u16 { /// drifts away from `tracing_enabled + Capture`, this test breaks /// before the customer-facing panic does. fn init_with_ee_otel_config() { - deno_telemetry::init( + windmill_runtime_nativets::init_telemetry( deno_telemetry::OtelRuntimeConfig { runtime_name: "windmill-nativets".into(), runtime_version: "0".into(), @@ -157,13 +157,12 @@ export async function main(): Promise {{ "fetch target should have been hit exactly once" ); - // 4. Force the BatchSpanProcessor to flush so the exporter posts - // to our mock collector synchronously (default flush interval - // is ~5s; tests can't wait that long). - deno_telemetry::flush(); - // Exporter is async over the OTel runtime; give it a beat to - // actually send the HTTP request. - tokio::time::sleep(Duration::from_millis(500)).await; + // 4. The BatchSpanProcessor exports on its own schedule (about 5s by + // default) and deno_telemetry exposes no flush, so wait for it. + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + while otlp_hits.lock().await.is_empty() && tokio::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(100)).await; + } let otlp_captured = otlp_hits.lock().await; assert!( diff --git a/backend/windmill-runtime-nativets/tests/otel_init.rs b/backend/windmill-runtime-nativets/tests/otel_init.rs index fdf1ac944d..80ef8683aa 100644 --- a/backend/windmill-runtime-nativets/tests/otel_init.rs +++ b/backend/windmill-runtime-nativets/tests/otel_init.rs @@ -46,7 +46,7 @@ async fn ee_otel_init_config_populates_otel_globals() { // Step 1: reproduce the footgun. `OtelConfig::default()` has // tracing/metrics off and `console = Ignore`, which trips the // 0.31 early-return — Ok(()) but OnceCell stays empty. - deno_telemetry::init( + windmill_runtime_nativets::init_telemetry( deno_telemetry::OtelRuntimeConfig { runtime_name: "windmill-nativets-test".into(), runtime_version: "0".into(), @@ -63,10 +63,17 @@ async fn ee_otel_init_config_populates_otel_globals() { tracing_enabled/console fields." ); + // The instance's own exporter shares this variable and may be set to gRPC, + // or to anything else. Native telemetry must not read it: a value + // deno_telemetry rejects outright shows it is never consulted. + // SAFETY: the only test in this binary, so nothing reads the environment + // concurrently. + unsafe { std::env::set_var("OTEL_EXPORTER_OTLP_PROTOCOL", "not-a-protocol") }; + // Step 2: re-call init with the exact config shape that the EE // `load_internal_otel_exporter` ships — this is what production // hits, so the test exercises the actual prod call path. - deno_telemetry::init( + windmill_runtime_nativets::init_telemetry( deno_telemetry::OtelRuntimeConfig { runtime_name: "windmill-nativets".into(), runtime_version: "0".into(), diff --git a/flake.nix b/flake.nix index f005644e3d..2a1d2699c8 100644 --- a/flake.nix +++ b/flake.nix @@ -78,15 +78,16 @@ ]; # --------------------------------------------------------------- - # Prebuilt V8 binary (must match version in Cargo.toml) + # Prebuilt V8 binary (must match the `v8` pin in backend/Cargo.toml). + # The simdutf variant: deno_core enables that feature of the v8 crate. # --------------------------------------------------------------- rustyV8Archive = let - version = "137.1.0"; + version = "150.4.0"; target = stdenv.hostPlatform.rust.rustcTarget; sha256 = { x86_64-linux = - "sha256-Tiscfy2bzYGR3s0T+SC1IB3xWvTVpVcSEdjq3MCRoRw="; + "sha256-9IdiyhDR8fxgWkQcWuQw7Izh6egPFNePvELLh4wwtHY="; aarch64-linux = lib.fakeHash; x86_64-darwin = lib.fakeHash; aarch64-darwin = lib.fakeHash; @@ -94,7 +95,7 @@ in pkgs.fetchurl { name = "librusty_v8-${version}"; url = - "https://github.com/denoland/rusty_v8/releases/download/v${version}/librusty_v8_release_${target}.a.gz"; + "https://github.com/denoland/rusty_v8/releases/download/v${version}/librusty_v8_simdutf_release_${target}.a.gz"; inherit sha256; }; From dedd77587f3234f3e480388b00340394794ac5bb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 10 Oct 2026 12:40:00 +0200 Subject: [PATCH 144/174] fix: keep preserve_step_tags on saved branch and loop flow nodes (#11660) * fix: keep preserve_step_tags on saved branch and loop flow nodes Co-Authored-By: Claude Opus 5.5 * style: format reduce_flow call Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/tests/worker.rs | 95 +++++++++++++++++++ .../windmill-worker/src/worker_lockfiles.rs | 9 ++ 2 files changed, 104 insertions(+) diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index eebb14e07e..751752eb00 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -5000,6 +5000,101 @@ async fn test_flow_lock_all(db: Pool) -> anyhow::Result<()> { Ok(()) } +/// A deployed flow runs a multi-step loop body as the flow node its deploy stored, which +/// carries its own copy of the flow's `preserve_step_tags`. A preview never loads that node. +#[sqlx::test(fixtures("base"))] +async fn test_saved_flow_preserves_nested_step_tags(db: Pool) -> anyhow::Result<()> { + use futures::StreamExt; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = reqwest::Client::new(); + let flow = json!({ + "path": "g/all/saved_step_tags", "summary": "", "description": "", "tag": "python3", + "schema": { "type": "object", "properties": {}, "required": [] }, + "value": { + "preserve_step_tags": true, + "modules": [{ + "id": "loop", + "value": { + "type": "forloopflow", "skip_failures": false, + "iterator": { "type": "static", "value": [2, 3] }, + "modules": [{ + "id": "own_tag", + "value": { + "type": "rawscript", "language": "bun", "lock": "", "tag": "bun", + "content": "export function main(n: number) { return n * n; }", + "input_transforms": { + "n": { "type": "javascript", "expr": "flow_input.iter.value" } + } + } + }, { + "id": "untagged", + "value": { + "type": "rawscript", "language": "bun", "lock": "", + "content": "export function main(n: number) { return n + 1; }", + "input_transforms": { + "n": { "type": "javascript", "expr": "results.own_tag" } + } + } + }] + } + }] + } + }); + let mut completed = listen_for_completed_jobs(&db).await; + client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/flows/create" + )) + .bearer_auth("SECRET_TOKEN") + .json(&flow) + .send() + .await? + .error_for_status()?; + let deployment = in_test_worker(&db, completed.next(), port).await.unwrap(); + let deployed = completed_job(deployment, &db).await; + assert!(deployed.success, "{:?}", deployed.result); + + let job_id = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/jobs/run/f/g/all/saved_step_tags" + )) + .bearer_auth("SECRET_TOKEN") + .json(&json!({})) + .send() + .await? + .error_for_status()? + .text() + .await? + .parse::()?; + in_test_worker(&db, completed.find(&job_id), port).await; + let job = completed_job(job_id, &db).await; + assert!(job.success, "{:?}", job.result); + let tags = sqlx::query_as::<_, (String, String)>( + "WITH RECURSIVE descendants AS ( + SELECT id FROM v2_job WHERE id = $1 + UNION ALL + SELECT child.id FROM v2_job child JOIN descendants ON child.parent_job = descendants.id + ) + SELECT DISTINCT flow_step_id, tag FROM v2_job + WHERE id IN (SELECT id FROM descendants) AND flow_step_id IN ('own_tag', 'untagged') + ORDER BY flow_step_id, tag", + ) + .bind(job_id) + .fetch_all(&db) + .await?; + assert_eq!( + tags, + vec![ + ("own_tag".to_owned(), "bun".to_owned()), + ("untagged".to_owned(), "python3".to_owned()) + ] + ); + Ok(()) +} + #[cfg(feature = "deno_core")] #[sqlx::test(fixtures("base"))] diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index b608601991..6189d39b8d 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -1244,6 +1244,7 @@ pub async fn handle_flow_dependency_job( &job.workspace_id, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, ) .await?; @@ -2134,6 +2135,7 @@ async fn insert_flow_modules<'c>( workspace_id: &str, failure_module: Option<&Box>, same_worker: bool, + preserve_step_tags: bool, modules: &mut Vec, modules_node: &mut Option, ) -> Result> { @@ -2144,6 +2146,7 @@ async fn insert_flow_modules<'c>( workspace_id, failure_module, same_worker, + preserve_step_tags, )) .await?; if modules.is_empty() || crate::worker_flow::is_simple_modules(modules, failure_module) { @@ -2160,6 +2163,7 @@ async fn insert_flow_modules<'c>( modules: std::mem::take(modules), failure_module: failure_module.cloned(), same_worker, + preserve_step_tags, ..Default::default() }))), None, @@ -2176,6 +2180,7 @@ async fn reduce_flow<'c>( workspace_id: &str, failure_module: Option<&Box>, same_worker: bool, + preserve_step_tags: bool, ) -> Result> { use FlowModuleValue::*; for module in &mut *modules { @@ -2234,6 +2239,7 @@ async fn reduce_flow<'c>( workspace_id, failure_module, same_worker, + preserve_step_tags, modules, modules_node, ) @@ -2247,6 +2253,7 @@ async fn reduce_flow<'c>( workspace_id, failure_module, same_worker, + preserve_step_tags, &mut branch.modules, &mut branch.modules_node, ) @@ -2258,6 +2265,7 @@ async fn reduce_flow<'c>( workspace_id, failure_module, same_worker, + preserve_step_tags, default, default_node, ) @@ -2271,6 +2279,7 @@ async fn reduce_flow<'c>( workspace_id, failure_module, same_worker, + preserve_step_tags, &mut branch.modules, &mut branch.modules_node, ) From 74c9b0cb2eebc16dd5e23f23be89b127bcbe6ec2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 10 Oct 2026 12:45:20 +0200 Subject: [PATCH 145/174] feat: add python 3.14 and 3.15 to the supported instance versions (#11654) Co-authored-by: Claude Opus 5.5 --- .../windmill-parser-py-imports/src/lib.rs | 11 ++++++++--- backend/src/main.rs | 5 ++++- backend/windmill-common/src/worker.rs | 17 +++++++++++++++-- backend/windmill-worker/src/worker.rs | 2 ++ cli/src/utils/script_common.ts | 4 +++- frontend/src/lib/components/instanceSettings.ts | 8 +++++++- 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/backend/parsers/windmill-parser-py-imports/src/lib.rs b/backend/parsers/windmill-parser-py-imports/src/lib.rs index de7f51bc79..72597da385 100644 --- a/backend/parsers/windmill-parser-py-imports/src/lib.rs +++ b/backend/parsers/windmill-parser-py-imports/src/lib.rs @@ -351,13 +351,16 @@ async fn parse_python_imports_inner( temp_script_refs: &Option>, ) -> error::Result> { tracing::debug!("Parsing python imports for path: {}", path); - let PythonAnnotations { py310, py311, py312, py313, .. } = PythonAnnotations::parse(&code); + let PythonAnnotations { py310, py311, py312, py313, py314, py315, .. } = + PythonAnnotations::parse(&code); tracing::debug!( - "Found python annotations - py310: {}, py311: {}, py312: {}, py313: {}", + "Found python annotations - py310: {}, py311: {}, py312: {}, py313: {}, py314: {}, py315: {}", py310, py311, py312, - py313 + py313, + py314, + py315 ); let mut push_version_specifiers = |perform, unparsed: String| -> error::Result<()> { @@ -373,6 +376,8 @@ async fn parse_python_imports_inner( push_version_specifiers(py311, "==3.11.*".to_owned())?; push_version_specifiers(py312, "==3.12.*".to_owned())?; push_version_specifiers(py313, "==3.13.*".to_owned())?; + push_version_specifiers(py314, "==3.14.*".to_owned())?; + push_version_specifiers(py315, "==3.15.*".to_owned())?; for x in code.lines() { if x.starts_with("# py:") || x.starts_with("#py:") { diff --git a/backend/src/main.rs b/backend/src/main.rs index 66ddce60e2..b8fdd9353f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -123,7 +123,8 @@ use windmill_worker::{ BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, CSHARP_CACHE_DIR, DENO_CACHE_DIR, DENO_CACHE_DIR_DEPS, DENO_CACHE_DIR_NPM, GO_BIN_CACHE_DIR, GO_CACHE_DIR, JAVA_CACHE_DIR, NU_CACHE_DIR, POWERSHELL_CACHE_DIR, PY310_CACHE_DIR, PY311_CACHE_DIR, PY312_CACHE_DIR, PY313_CACHE_DIR, - RUBY_CACHE_DIR, RUST_CACHE_DIR, R_CACHE_DIR, TAR_JAVA_CACHE_DIR, UV_CACHE_DIR, + PY314_CACHE_DIR, PY315_CACHE_DIR, RUBY_CACHE_DIR, RUST_CACHE_DIR, R_CACHE_DIR, + TAR_JAVA_CACHE_DIR, UV_CACHE_DIR, }; use crate::monitor::{ @@ -2179,6 +2180,8 @@ pub async fn run_workers( &*PY311_CACHE_DIR, &*PY312_CACHE_DIR, &*PY313_CACHE_DIR, + &*PY314_CACHE_DIR, + &*PY315_CACHE_DIR, &*BUN_BUNDLE_CACHE_DIR, &*GO_CACHE_DIR, &*GO_BIN_CACHE_DIR, diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 9bc788d9f3..58223c5d4a 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -1201,6 +1201,8 @@ pub struct PythonAnnotations { pub py311: bool, pub py312: bool, pub py313: bool, + pub py314: bool, + pub py315: bool, pub sandbox: bool, } @@ -2892,6 +2894,8 @@ pub enum PyVAlias { #[default] Py312, Py313, + Py314, + Py315, } impl Into for PyVAlias { @@ -2909,13 +2913,20 @@ impl Into for PyVAlias { impl PyVAlias { pub fn all>() -> Vec { use PyVAlias::*; - vec![Py310.into(), Py311.into(), Py312.into(), Py313.into()] + vec![ + Py310.into(), + Py311.into(), + Py312.into(), + Py313.into(), + Py314.into(), + Py315.into(), + ] } // Get MAJOR part of alias. (semver: MAJOR.MINOR.PATCH) fn major(&self) -> u32 { use PyVAlias::*; match self { - Py310 | Py311 | Py312 | Py313 => 3, + Py310 | Py311 | Py312 | Py313 | Py314 | Py315 => 3, // Py400 | Py401 => 4 } } @@ -2930,6 +2941,8 @@ impl PyVAlias { "311" => Some(Py311), "312" => Some(Py312), "313" => Some(Py313), + "314" => Some(Py314), + "315" => Some(Py315), _ => None, } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 5ae626f27e..7c3c3971fa 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -215,6 +215,8 @@ lazy_static::lazy_static! { pub static ref PY311_CACHE_DIR: String = format!("{}python_3_11", *ROOT_CACHE_DIR); pub static ref PY312_CACHE_DIR: String = format!("{}python_3_12", *ROOT_CACHE_DIR); pub static ref PY313_CACHE_DIR: String = format!("{}python_3_13", *ROOT_CACHE_DIR); + pub static ref PY314_CACHE_DIR: String = format!("{}python_3_14", *ROOT_CACHE_DIR); + pub static ref PY315_CACHE_DIR: String = format!("{}python_3_15", *ROOT_CACHE_DIR); pub static ref TAR_JAVA_CACHE_DIR: String = format!("{}tar/java", *ROOT_CACHE_DIR); diff --git a/cli/src/utils/script_common.ts b/cli/src/utils/script_common.ts index 611268c27c..e3deabb6a7 100644 --- a/cli/src/utils/script_common.ts +++ b/cli/src/utils/script_common.ts @@ -189,6 +189,8 @@ const LANG_ANNOTATIONS: Partial> = { "py311", "py312", "py313", + "py314", + "py315", "sandbox", ], bun: ["npm", "nodejs", "native", "nobundling", "sandbox"], @@ -208,7 +210,7 @@ const LANG_ANNOTATIONS: Partial> = { * * `# py: ` is the exception the macro does not cover: the python * import parser reads it directly (`windmill-parser-py-imports`, alongside the - * `py310`..`py313` flags) to pick the interpreter, which changes what resolves. + * `py310`..`py315` flags) to pick the interpreter, which changes what resolves. */ export function hasLockAffectingAnnotation( scriptContent: string, diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 867570e1a1..83fba2b8ac 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -840,7 +840,7 @@ export const settings: Record = { // 1. Change placeholder in instanceSettings.ts // 2. Change LATEST_STABLE_PY in dockerfile // 3. Change #[default] annotation for PyVersion in backend - placeholder: '3.10,3.11,3.12,3.13', + placeholder: '3.10,3.11,3.12,3.13,3.14,3.15', select_items: [ { label: 'Latest Stable', @@ -858,6 +858,12 @@ export const settings: Record = { }, { label: '3.13' + }, + { + label: '3.14' + }, + { + label: '3.15' } ], storage: 'setting' From 0250a90849fc01589e8544b91c680277570a68b6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 10 Oct 2026 12:50:56 +0200 Subject: [PATCH 146/174] chore: bump tokio to 1.53 lts (#11663) * chore: bump tokio to 1.51 lts Co-Authored-By: Claude Opus 5.5 * chore: move tokio pin to 1.53 lts Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/AGENTS.md | 2 +- backend/Cargo.lock | 73 +++++----------------------------------------- backend/Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 68 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 750e0594e1..fbacb857ee 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -214,7 +214,7 @@ Whole classes of defect compile and unit-test clean: - **Stack overflow from a large buffer in an async block.** An array declared across an `.await` is baked into the future's state; once that future is boxed a few layers deep by the job poller, two 16 KB arrays abort the worker *process* (`thread - 'tokio-runtime-worker' has overflowed its stack`). Heap-allocate read buffers + 'tokio-rt-worker' has overflowed its stack`). Heap-allocate read buffers (`vec![0u8; N]`, not `[0u8; N]`). - Deadlocks from draining only one of a child's pipes, missed cancellation or timeout propagation, and anything depending on the real engine's output format. diff --git a/backend/Cargo.lock b/backend/Cargo.lock index efd46867fb..ce517a7487 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,15 +2,6 @@ # 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" @@ -247,7 +238,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ - "object 0.39.1", + "object", ] [[package]] @@ -1623,21 +1614,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 0.8.9", - "object 0.37.3", - "rustc-demangle", - "windows-link 0.2.1", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -5921,12 +5897,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "git-version" version = "0.3.9" @@ -7055,17 +7025,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "io-uring" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "libc", -] - [[package]] name = "ipconfig" version = "0.3.4" @@ -8803,15 +8762,6 @@ dependencies = [ "url", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "object" version = "0.39.1" @@ -11201,12 +11151,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -13934,23 +13878,20 @@ dependencies = [ [[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 0.6.5", "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -13979,13 +13920,13 @@ dependencies = [ [[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.119", + "syn 3.0.6", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e91ad01795..306a3ae9f6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -451,7 +451,7 @@ headers = "^0" hyper = { version = "^1", features = ["full"] } hyper-tls = "^0.6" hyper-util = { version = "^0.1", features = ["client-legacy", "http1", "tokio"] } -tokio = { version = "=1.47.1", features = ["full", "tracing", "time"] } +tokio = { version = "=1.53.2", features = ["full", "tracing", "time"] } tokio-stream = { version = "0.1.17" } tower = "^0" tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] } From ed7a859e8c4799bed086baf707b817a68f93d2a4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 10 Oct 2026 13:06:58 +0200 Subject: [PATCH 147/174] chore: bump uv to 0.13.0 (#11665) * chore: bump uv to 0.13.0 Co-Authored-By: Claude Opus 5.5 * fix: pin the python ansible is installed on in the full images Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/DockerfileBackendTests | 2 +- .github/workflows/backend-test-windows.yml | 2 +- .github/workflows/backend-test.yml | 2 +- Dockerfile | 2 +- docker/DockerfileFull | 4 +++- docker/DockerfileFullEe | 4 +++- docker/DockerfileSlim | 2 +- docker/DockerfileSlimEe | 2 +- 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/DockerfileBackendTests b/.github/DockerfileBackendTests index 87a65214ab..2ce47ea714 100644 --- a/.github/DockerfileBackendTests +++ b/.github/DockerfileBackendTests @@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GO_PATH=/usr/local/go/bin/go # UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv ENV TZ=Etc/UTC diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index 73fe57ca6a..3468449753 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -81,7 +81,7 @@ jobs: - uses: astral-sh/setup-uv@v7 with: - version: "0.11.24" + version: "0.13.0" - uses: shivammathur/setup-php@v2 with: diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index a9a2c573d6..cdfda7e0d4 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -64,7 +64,7 @@ jobs: node-version: "24" - uses: astral-sh/setup-uv@v7 with: - version: "0.11.24" + version: "0.13.0" - uses: shivammathur/setup-php@v2 with: php-version: "8.3" diff --git a/Dockerfile b/Dockerfile index fb4583bf1d..faaa49dd8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -242,7 +242,7 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GO_PATH=/usr/local/go/bin/go # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtimes to temp build location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run diff --git a/docker/DockerfileFull b/docker/DockerfileFull index 3528ead3aa..c3a82c8312 100644 --- a/docker/DockerfileFull +++ b/docker/DockerfileFull @@ -15,7 +15,9 @@ RUN RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo /usr/local/cargo/b # ansible there leaves its venv interpreter as a dangling symlink at runtime, so every # ansible-* executable fails with ENOENT ("ansible-galaxy not found"). Pin the tool's # interpreter to a persistent image path so the install stays self-contained. -RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true +# The version is explicit because uv otherwise downloads its newest stable Python, which +# ansible-core may not support yet: the install succeeds and every executable fails on import. +RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install --python 3.14 ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true # C# RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ diff --git a/docker/DockerfileFullEe b/docker/DockerfileFullEe index 1dd1a1eb6d..bb83195f62 100644 --- a/docker/DockerfileFullEe +++ b/docker/DockerfileFullEe @@ -35,7 +35,9 @@ RUN RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo /usr/local/cargo/b # ansible there leaves its venv interpreter as a dangling symlink at runtime, so every # ansible-* executable fails with ENOENT ("ansible-galaxy not found"). Pin the tool's # interpreter to a persistent image path so the install stays self-contained. -RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true +# The version is explicit because uv otherwise downloads its newest stable Python, which +# ansible-core may not support yet: the install succeeds and every executable fails on import. +RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install --python 3.14 ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true # dotnet SDK RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ && chmod +x dotnet-install.sh \ diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 45986bbd5f..a37ced0eca 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -58,7 +58,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo ENV TZ=Etc/UTC # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtime to temp location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 5ac24f5c1d..687b1506ce 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -58,7 +58,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo ENV TZ=Etc/UTC # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtime to temp location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run From 0758bdc46b3c8391914f18718730ee8cbf18cf6a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 11 Oct 2026 11:22:46 +0200 Subject: [PATCH 148/174] refactor: remove the frontend build-time base url (#11674) Co-authored-by: Claude Opus 5.5 --- Dockerfile | 1 - frontend/src/lib/apiBaseUrl.svelte.ts | 3 +- frontend/src/lib/base.ts | 1 - frontend/src/lib/components/AgentTrace.svelte | 3 +- .../src/lib/components/AppConnectInner.svelte | 3 +- frontend/src/lib/components/ArgInput.svelte | 3 +- .../src/lib/components/CiTestResults.svelte | 5 ++- .../lib/components/CompareWorkspaces.svelte | 3 +- .../src/lib/components/DeployWorkspace.svelte | 5 ++- .../lib/components/DevWorkspaceSetting.svelte | 5 ++- .../src/lib/components/DisplayResult.svelte | 4 +-- .../components/DisplayResultControlBar.svelte | 4 +-- frontend/src/lib/components/EditorBar.svelte | 7 ++--- .../components/ErrorOrRecoveryHandler.svelte | 7 ++--- .../src/lib/components/FlowLogViewer.svelte | 3 +- .../src/lib/components/FlowMetadata.svelte | 9 +++--- .../components/FlowStatusViewerInner.svelte | 5 ++- .../src/lib/components/GitRepoViewer.svelte | 5 ++- frontend/src/lib/components/LogViewer.svelte | 3 +- frontend/src/lib/components/Login.svelte | 7 ++--- .../src/lib/components/NavBreadcrumb.svelte | 5 ++- .../components/ParqetCsvTableRenderer.svelte | 3 +- .../components/PersistentScriptDrawer.svelte | 5 ++- frontend/src/lib/components/RunsPage.svelte | 4 +-- .../lib/components/S3FilePickerInner.svelte | 5 ++- .../src/lib/components/ScriptBuilder.svelte | 3 +- .../src/lib/components/ScriptEditor.svelte | 5 ++- .../src/lib/components/ScriptPicker.svelte | 9 +++--- .../components/SuperadminSettingsInner.svelte | 11 +++---- .../src/lib/components/TimelineBar.svelte | 3 +- .../components/UpdateDevWorkspaceModal.svelte | 3 +- .../lib/components/WorkflowTimeline.svelte | 3 +- .../lib/components/aiEvals/EvalScorers.svelte | 5 ++- .../lib/components/aiEvals/EvalsPane.svelte | 5 ++- .../apps/components/helpers/eval.ts | 5 ++- .../apps/editor/AppEditorHeaderDeploy.svelte | 3 +- .../apps/editor/AppReportsDrawerInner.svelte | 3 +- .../apps/editor/InWorkspaceAppViewer.svelte | 7 ++--- .../apps/editor/PublicAppAccess.svelte | 3 +- .../apps/editor/PublicAppFrame.svelte | 3 +- .../apps/editor/appDeploy.svelte.ts | 3 +- .../InlineScriptRunnableByPath.svelte | 5 ++- .../AssetGraph/AssetGraphDetailsPane.svelte | 7 ++--- .../assets/AssetGraph/AssetRunsPanel.svelte | 3 +- .../assets/AssetGraph/DataTablePreview.svelte | 3 +- .../AssetGraph/PipelineActivityPanel.svelte | 3 +- .../AssetGraph/PipelineFolderList.svelte | 3 +- .../AssetGraph/PipelineSetupSignpost.svelte | 5 ++- .../DraftEditorModals.svelte | 3 +- .../OtherUsersDraftsModal.svelte | 3 +- .../common/fileDownload/FileDownload.svelte | 3 +- .../common/slack/SlackConnectionStatus.svelte | 5 ++- .../components/common/table/AgentRow.svelte | 5 ++- .../lib/components/common/table/AppRow.svelte | 9 +++--- .../components/common/table/FlowRow.svelte | 13 ++++---- .../common/table/PipelineRow.svelte | 3 +- .../components/common/table/RawAppRow.svelte | 3 +- .../components/common/table/ScriptRow.svelte | 13 ++++---- .../common/teams/TeamsConnectionStatus.svelte | 3 +- .../components/copilot/AIFormAssistant.svelte | 3 +- .../src/lib/components/copilot/CronGen.svelte | 3 +- .../copilot/FlowInlineScriptAIButton.svelte | 3 +- .../lib/components/copilot/RegexGen.svelte | 3 +- .../lib/components/copilot/ResourceGen.svelte | 3 +- .../lib/components/copilot/ScriptFix.svelte | 3 +- .../lib/components/copilot/ScriptGen.svelte | 3 +- .../components/copilot/StepInputsGen.svelte | 3 +- .../components/copilot/chat/AIButton.svelte | 3 +- .../lib/components/copilot/chat/AIChat.svelte | 3 +- .../copilot/chat/AIChatDisplay.svelte | 5 ++- .../copilot/chat/AIChatManager.test.ts | 1 - .../copilot/chat/AIChatModelSettings.svelte | 3 +- .../chat/AssistantInstructionsSection.svelte | 3 +- .../copilot/chat/AssistantMessage.svelte | 3 +- .../copilot/chat/JobsSegment.svelte | 3 +- .../copilot/chat/RunScriptCard.svelte | 3 +- .../copilot/chat/ToolExecutionDisplay.svelte | 3 +- .../chat/artifacts/artifactSharing.test.ts | 2 -- .../copilot/chat/artifacts/artifactSharing.ts | 4 +-- .../components/copilot/chat/global/core.ts | 5 ++- .../lib/components/dbt/DbtModelGraph.svelte | 5 ++- .../lib/components/details/FlowDetail.svelte | 7 ++--- .../components/details/ScriptDetail.svelte | 9 +++--- .../components/flows/CreateActionsApp.svelte | 5 ++- .../components/flows/CreateActionsFlow.svelte | 11 +++---- .../flows/content/ExpandedSubflowStep.svelte | 3 +- .../flows/content/FlowEditorDrawer.svelte | 3 +- .../flows/conversations/messageAttachments.ts | 3 +- .../flows/propPicker/OutputBadge.svelte | 3 +- .../flows/propPicker/OutputPickerInner.svelte | 3 -- .../components/git_sync/GitSyncSection.svelte | 3 +- .../components/home/CreateActionsMenu.svelte | 31 +++++++++---------- .../src/lib/components/home/HomeAIChat.svelte | 3 +- .../jobs/MissingWorkerTagAlert.svelte | 3 +- .../src/lib/components/mcp/McpConnect.svelte | 3 +- .../components/otherUserDraftLoad.svelte.ts | 9 +++--- .../preview/FlowPreviewStatus.svelte | 3 +- .../raw_apps/RawAppEditorHeader.svelte | 7 ++--- .../raw_apps/RawAppTemplatePicker.svelte | 7 ++--- .../runs/DispatchEventsTable.svelte | 3 +- .../components/runs/JobDetailHeader.svelte | 15 +++++---- .../src/lib/components/runs/RunBadges.svelte | 7 ++--- .../src/lib/components/runs/RunRow.svelte | 7 ++--- .../components/schedules/SchedulesList.svelte | 11 +++---- .../components/schema/JobSchemaPicker.svelte | 3 +- .../schema/RunningJobSchemaPicker.svelte | 3 +- .../components/scriptEditor/LogPanel.svelte | 3 +- .../scripts/CreateActionsScript.svelte | 3 +- .../components/sessions/ItemViewerView.svelte | 7 ++--- .../components/sessions/PageListView.svelte | 2 +- .../sessions/PreviewRouterPicker.svelte | 5 ++- .../sessions/RawAppEditorView.svelte | 3 +- .../sessions/SessionModeSwitch.svelte | 3 +- .../components/sessions/SessionPicker.svelte | 3 +- .../sessions/SessionsBetaBanner.svelte | 3 +- .../components/sessions/pageDrawerSession.ts | 11 +++---- .../lib/components/sessions/previewPaths.ts | 26 +++++++--------- .../lib/components/sessions/previewReload.ts | 6 ++-- .../lib/components/sessions/previewRouter.ts | 27 +++++++--------- .../components/sessions/sessionMode.svelte.ts | 7 ++--- .../sessions/sessionPreviewTabs.svelte.ts | 13 ++++---- .../sessions/sessionPreviewTabs.test.ts | 25 +++++++-------- .../sessions/sessionRuntime.svelte.ts | 5 ++- .../components/settings/PremiumInfo.svelte | 5 ++- .../sidebar/CriticalAlertModal.svelte | 7 ++--- .../sidebar/FinishAccountSetup.svelte | 3 +- .../lib/components/sidebar/MenuLink.svelte | 3 +- .../components/sidebar/SettingsMenu.svelte | 15 +++++---- .../components/sidebar/SidebarContent.svelte | 15 +++++---- .../sidebar/WorkspacePickerBody.svelte | 7 ++--- .../sidebar/WorkspaceScopeHeader.svelte | 3 +- .../lib/components/sidebar/navPages.test.ts | 12 +++---- .../src/lib/components/sidebar/navPages.ts | 3 +- .../components/sidebar/workspaceMenuHref.ts | 4 +-- .../components/triggers/CaptureSection.svelte | 2 -- .../triggers/TriggerEditorToolbar.svelte | 4 +-- .../components/triggers/TriggerList.svelte | 5 ++- .../azure/AzureTriggerEditorInner.svelte | 3 +- .../components/triggers/gcp/GcpCapture.svelte | 3 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 3 +- .../triggers/http/RouteCapture.svelte | 3 +- .../PostgresTriggerEditorInner.svelte | 3 +- .../scheduled/ScheduledPollPanel.svelte | 5 ++- .../schedules/ScheduleEditorInner.svelte | 3 +- .../CreateWorkspaceInner.svelte | 3 +- .../workspaceSettings/DucklakeSettings.svelte | 3 +- .../deployToHubSession.svelte.ts | 5 ++- .../workspaceSettings/supabaseOauth.svelte.ts | 3 +- .../workspaceSettings/supabaseProvisioning.ts | 11 +++---- frontend/src/lib/draftAddRedirect.ts | 5 ++- frontend/src/lib/githubApp.ts | 3 +- frontend/src/lib/navigation.test.ts | 3 +- frontend/src/lib/navigation.ts | 15 ++------- frontend/src/lib/remoteDeploy.ts | 3 +- frontend/src/lib/scripts.ts | 5 ++- frontend/src/lib/utils/editInFork.ts | 11 +++---- frontend/src/lib/workspaceCreation.ts | 3 +- .../src/routes/(root)/(logged)/+layout.svelte | 15 +++++---- .../src/routes/(root)/(logged)/+page.svelte | 7 ++--- .../agents/edit/[...path]/+page.svelte | 5 ++- .../agents/get/[...path]/+page.svelte | 7 ++--- .../get_raw/[version]/[...path]/+page.svelte | 3 +- .../(logged)/concurrency_groups/+page.svelte | 3 +- .../(logged)/pipeline/[folder]/+page.svelte | 7 ++--- .../projects/import/+page@(root).svelte | 3 +- .../(root)/(logged)/projects/install/+page.ts | 3 +- .../(root)/(logged)/run/[...run]/+page.svelte | 9 +++--- .../(root)/(logged)/sessions/+page.svelte | 17 +++++----- .../user/(user)/accept_invite/+page.svelte | 3 +- .../(logged)/user/(user)/login/+page.svelte | 3 +- .../user/(user)/workspaces/+page.svelte | 7 ++--- .../(root)/(logged)/user/cli/+page.svelte | 3 +- .../user/remote_deploy_authorize/+page.svelte | 3 +- .../(logged)/workspace_settings/+page.svelte | 23 +++++++------- frontend/src/routes/(root)/+layout.svelte | 5 ++- .../approve/[workspace]/[job]/+page.svelte | 3 +- .../[job]/[resume]/[hmac]/+page.svelte | 3 +- .../public_run/[workspace]/[id]/+page.svelte | 3 +- frontend/src/routes/a/[...path]/+page.svelte | 3 +- .../[workspace]/[...secret]/+page.svelte | 3 +- frontend/svelte.config.js | 3 -- 181 files changed, 393 insertions(+), 596 deletions(-) delete mode 100644 frontend/src/lib/base.ts diff --git a/Dockerfile b/Dockerfile index faaa49dd8d..c6a4ccc55f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,7 +83,6 @@ COPY /python-client/docs/ /frontend/static/pydocs/ RUN npm run generate-backend-client ENV NODE_OPTIONS "--max-old-space-size=8192" -ARG VITE_BASE_URL "" # Must be declared for the build-arg to reach the bundle. See frontend/svelte.config.js. ARG WM_BUILD_VERSION="" # Read more about macro in docker/dev.nu diff --git a/frontend/src/lib/apiBaseUrl.svelte.ts b/frontend/src/lib/apiBaseUrl.svelte.ts index f87a33f0d8..242a6aa9e0 100644 --- a/frontend/src/lib/apiBaseUrl.svelte.ts +++ b/frontend/src/lib/apiBaseUrl.svelte.ts @@ -1,5 +1,4 @@ import { fromStore } from 'svelte/store' -import { base } from '$lib/base' import { instanceUi } from '$lib/instanceUi' const ui = fromStore(instanceUi) @@ -18,5 +17,5 @@ const ui = fromStore(instanceUi) export function apiBaseUrl(): string { const setting = ui.current?.api_base_url const configured = typeof setting === 'string' ? setting.trim() : '' - return configured || `${window.location.origin}${base}` + return configured || window.location.origin } diff --git a/frontend/src/lib/base.ts b/frontend/src/lib/base.ts deleted file mode 100644 index 0e8f2d71f6..0000000000 --- a/frontend/src/lib/base.ts +++ /dev/null @@ -1 +0,0 @@ -export let base: string = import.meta.env.VITE_BASE_URL ?? '' diff --git a/frontend/src/lib/components/AgentTrace.svelte b/frontend/src/lib/components/AgentTrace.svelte index 7f2a90e621..3d671f46c8 100644 --- a/frontend/src/lib/components/AgentTrace.svelte +++ b/frontend/src/lib/components/AgentTrace.svelte @@ -1,7 +1,6 @@ diff --git a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte index ce9b07768e..b6ec78f336 100644 --- a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte @@ -22,7 +22,6 @@ import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { goto } from '$app/navigation' - import { base } from '$app/paths' export type OtherDraftUser = { username?: string | null; draft_saved_at?: string } @@ -115,7 +114,7 @@ target === 'all' || (!hasOwnDraft && otherDraftsUsers.every((o) => ownerKey(o) === ownerKey(target))) if (draftOnly && lastDraftGone) { - await goto(`${base}/`) + await goto('/') return } try { diff --git a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte index c1a29fae70..7f9187da62 100644 --- a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte +++ b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte @@ -1,6 +1,5 @@ diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index 54142a4822..aca6ae7f9c 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -1,5 +1,4 @@ {#snippet chatBadge()} diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 50bc64f24b..a318c1f412 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -1,5 +1,4 @@ import { goto } from '$lib/navigation' - import { base } from '$lib/base' import Dropdown from '$lib/components/DropdownV2.svelte' import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' @@ -157,8 +156,8 @@ aiId={`script-run-button-${script.path}`} aiDescription={`Button to access the form to run the script ${script.summary ?? script.path}`} href={script.draft_only || (script.auto_kind === 'lib' && script.kind !== 'preprocessor') - ? `${base}/scripts/edit/${script.path}` - : `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`} + ? `/scripts/edit/${script.path}` + : `/scripts/get/${script.hash}?workspace=${$workspaceStore}`} kind="script" {keyboardSelected} {marked} @@ -261,7 +260,7 @@ wrapperClasses="w-20" unifiedSize="md" startIcon={{ icon: Pen }} - href="{base}/scripts/edit/{script.path}" + href="/scripts/edit/{script.path}" > Edit @@ -351,7 +350,7 @@ { displayName: 'Duplicate/Fork', icon: GitFork, - href: `${base}/scripts/add?template=${script.path}`, + href: `/scripts/add?template=${script.path}`, disabled: !showEditButton, hide: $userStore?.operator }, @@ -391,7 +390,7 @@ { displayName: 'View runs', icon: List, - href: `${base}/runs/${script.path}` + href: `/runs/${script.path}` }, { displayName: 'Versions', @@ -403,7 +402,7 @@ { displayName: 'Audit logs', icon: Eye, - href: `${base}/audit_logs?resource=${script.path}`, + href: `/audit_logs?resource=${script.path}`, hide: $userStore?.operator }, { diff --git a/frontend/src/lib/components/common/teams/TeamsConnectionStatus.svelte b/frontend/src/lib/components/common/teams/TeamsConnectionStatus.svelte index ce368e93b9..64b9269f3d 100644 --- a/frontend/src/lib/components/common/teams/TeamsConnectionStatus.svelte +++ b/frontend/src/lib/components/common/teams/TeamsConnectionStatus.svelte @@ -1,7 +1,6 @@ diff --git a/frontend/src/lib/components/copilot/AIFormAssistant.svelte b/frontend/src/lib/components/copilot/AIFormAssistant.svelte index b4526f1153..48a46ad182 100644 --- a/frontend/src/lib/components/copilot/AIFormAssistant.svelte +++ b/frontend/src/lib/components/copilot/AIFormAssistant.svelte @@ -8,7 +8,6 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { copilotInfo } from '$lib/aiStore' import { logFeatureUsage } from '$lib/utils/featureUsage' - import { pageHref } from '$lib/components/sessions/previewPaths' interface Props { /** Unset for a viewer who cannot edit the item: the card then offers no way to the editor. */ @@ -45,7 +44,7 @@ const sessionSource = $derived( path ? { - page: () => pageHref(`/${runnableType}s/get/${path}`), + page: () => `/${runnableType}s/get/${path}`, workspaceId: $operatingWorkspace ?? undefined, beforeOpen: logAsked } diff --git a/frontend/src/lib/components/copilot/CronGen.svelte b/frontend/src/lib/components/copilot/CronGen.svelte index 94715a0f18..fb5806234c 100644 --- a/frontend/src/lib/components/copilot/CronGen.svelte +++ b/frontend/src/lib/components/copilot/CronGen.svelte @@ -5,7 +5,6 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import { sendUserToast } from '$lib/toast' - import { base } from '$lib/base' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' import { copilotInfo } from '$lib/aiStore' import { untrack } from 'svelte' @@ -132,7 +131,7 @@

Enable Windmill AI in the workspace settings

Enable Windmill AI in the diff --git a/frontend/src/lib/components/copilot/RegexGen.svelte b/frontend/src/lib/components/copilot/RegexGen.svelte index f399726988..32fdc4a5f1 100644 --- a/frontend/src/lib/components/copilot/RegexGen.svelte +++ b/frontend/src/lib/components/copilot/RegexGen.svelte @@ -1,7 +1,6 @@ diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 38f91353a2..832a96d6b4 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -13,7 +13,6 @@ ExternalLink, BookOpen } from 'lucide-svelte' - import { base } from '$lib/base' import { truncateRev } from '$lib/utils' import { EXIT_PLAN_MODE_TOOL, @@ -301,7 +300,7 @@ {#snippet jobLink()} ({ base: '' })) - import { shareWorkspaceId } from './artifactSharing' describe('shareWorkspaceId', () => { diff --git a/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.ts b/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.ts index 57b08cba9b..269eb6354b 100644 --- a/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.ts +++ b/frontend/src/lib/components/copilot/chat/artifacts/artifactSharing.ts @@ -1,5 +1,3 @@ -import { base } from '$lib/base' - /** * The workspace a share from `workspaceId` lands in: the topmost ancestor the user still * belongs to. A session often runs in a fork, whose members are its creator alone, so a link @@ -20,7 +18,7 @@ export function shareWorkspaceId( } export function sharedArtifactUrl(workspaceId: string, shareId: string): string { - return `${window.location.origin}${base}/shared_artifacts/${encodeURIComponent( + return `${window.location.origin}/shared_artifacts/${encodeURIComponent( shareId )}?workspace=${encodeURIComponent(workspaceId)}` } diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index b6eac0439c..2a795f1f81 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -246,7 +246,6 @@ import { parseItemsMaskParam } from '$lib/components/sessions/modifiedItemsMask' import { - pageHref, TRIGGER_PAGES, type TriggerKind as PageTriggerKind } from '$lib/components/sessions/previewRouter' @@ -336,7 +335,7 @@ export type GlobalActiveEditorContext = { export type GlobalActivePreviewContext = { /** Page name as the tab strip shows it, e.g. "Schedules". */ label: string - /** Base-stripped page path plus the request params the page declares — values kept + /** Page path plus the request params the page declares — values kept * only for the ones addressing a workspace object, and percent-encoded. Never a raw * location: a tab can host a legacy app whose hash is app state, and a filter value * can be free text the user typed. Build it with `previewLocationContext`. */ @@ -3461,7 +3460,7 @@ export const openPageTool: SessionTool<{}> = { // is no active session, in which case we offer a link chip instead. const previewResult = openPagePreview({ sessionId: sessionIdFromCtx(ctx), - href: pageHref(url), + href: url, label: pageLabel, newTab: parsed.new_tab ?? false }) diff --git a/frontend/src/lib/components/dbt/DbtModelGraph.svelte b/frontend/src/lib/components/dbt/DbtModelGraph.svelte index bd7f42a9e7..12b008812d 100644 --- a/frontend/src/lib/components/dbt/DbtModelGraph.svelte +++ b/frontend/src/lib/components/dbt/DbtModelGraph.svelte @@ -16,7 +16,6 @@ import { Button } from '$lib/components/common' import { Loader2, RefreshCw } from 'lucide-svelte' import { displayDate } from '$lib/utils' - import { base } from '$lib/base' import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte' import type { DbtPreviewBuffer } from './previewRows' import type { @@ -477,7 +476,7 @@ worker tag nothing serves waits here indefinitely. Open the parse job @@ -491,7 +490,7 @@ {#if refreshError.job} Open the parse job diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index c755136748..30de5dc7cb 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -23,7 +23,6 @@ import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte' import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte' import { goto } from '$lib/navigation' - import { base } from '$lib/base' import { Badge as HeaderBadge, Alert } from '$lib/components/common' import MoveDrawer from '$lib/components/MoveDrawer.svelte' import RunForm from '$lib/components/RunForm.svelte' @@ -372,7 +371,7 @@ description: `Start a new flow from a copy of this one`, narrow: { dropdownOf: 'Edit' }, buttonProps: { - href: operatingHref(`${base}/flows/add?template=${flow.path}`), + href: operatingHref(`/flows/add?template=${flow.path}`), onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`), variant: 'subtle', unifiedSize: 'md', @@ -409,7 +408,7 @@ buttons.push({ label: `Runs`, buttonProps: { - href: operatingHref(`${base}/runs/${flow.path}`), + href: operatingHref(`/runs/${flow.path}`), onClick: interceptNav(onNavigate, `/runs/${flow.path}`), unifiedSize: 'md', variant: 'subtle', @@ -455,7 +454,7 @@ buttons.push({ label: 'Edit', buttonProps: { - href: operatingHref(`${base}/flows/edit/${path}`), + href: operatingHref(`/flows/edit/${path}`), onClick: interceptNav(onNavigate, `/flows/edit/${path}`), variant: 'accent', unifiedSize: 'md', diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index e8536aed91..6712092c39 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -1,5 +1,4 @@ diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index ae848156f5..9f78585cc0 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -1,6 +1,5 @@ diff --git a/frontend/src/lib/components/flows/content/ExpandedSubflowStep.svelte b/frontend/src/lib/components/flows/content/ExpandedSubflowStep.svelte index 979717dae1..9002e88adf 100644 --- a/frontend/src/lib/components/flows/content/ExpandedSubflowStep.svelte +++ b/frontend/src/lib/components/flows/content/ExpandedSubflowStep.svelte @@ -13,7 +13,6 @@ type ResolvedExpandedSubflowStep } from '../expandedSubflowStep' import { parseExpandedSubflowId } from '$lib/components/restartFromStepPath' - import { base } from '$app/paths' import FlowPanelChrome from '../common/FlowPanelChrome.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -122,7 +121,7 @@ title="Open the subflow in a new tab" startIcon={{ icon: ExternalLink }} iconOnly - href={`${base}/flows/edit/${containingFlowPath}?workspace=${encodeURIComponent( + href={`/flows/edit/${containingFlowPath}?workspace=${encodeURIComponent( opWs ?? '' )}${module ? `&selected=${encodeURIComponent(leafId)}` : ''}`} target="_blank" diff --git a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte index 6b4d73c6a0..c6c1d13b2a 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte @@ -11,7 +11,6 @@ import { initFlow } from '$lib/components/flows/flowStore.svelte' import type { FlowState } from '$lib/components/flows/flowState' import type { FlowEditorContext } from '../types' - import { base } from '$app/paths' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -127,7 +126,7 @@ // Follow the drawer's own selection, which the user may have moved since it opened. const stepId = flowBuilder?.getSelectedId() ?? selectedStepId const selected = stepId ? `?selected=${encodeURIComponent(stepId)}` : '' - window.open(`${base}/flows/edit/${flowPath}${selected}`, '_blank', 'noopener,noreferrer') + window.open(`/flows/edit/${flowPath}${selected}`, '_blank', 'noopener,noreferrer') flowEditorDrawer?.closeDrawer() }} startIcon={{ icon: ExternalLink }} diff --git a/frontend/src/lib/components/flows/conversations/messageAttachments.ts b/frontend/src/lib/components/flows/conversations/messageAttachments.ts index 36c7d103c3..18170a90df 100644 --- a/frontend/src/lib/components/flows/conversations/messageAttachments.ts +++ b/frontend/src/lib/components/flows/conversations/messageAttachments.ts @@ -1,6 +1,5 @@ /** The files a user message carried, as the lanes its bubble renders: image thumbnails and file chips. */ import type { ChatAttachment } from 'windmill-chat' -import { base } from '$lib/base' import { createAttachedFileContextElement, type ContextElement @@ -24,7 +23,7 @@ function looksLikeImage(attachment: ChatAttachment): boolean { function downloadUrl(workspace: string, attachment: ChatAttachment): string { const params = new URLSearchParams({ file_key: attachment.s3 }) if (attachment.storage) params.set('storage', attachment.storage) - return `${base}/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}` + return `/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}` } /** Without a workspace there is no link to build, so an image falls back to a file chip. */ diff --git a/frontend/src/lib/components/flows/propPicker/OutputBadge.svelte b/frontend/src/lib/components/flows/propPicker/OutputBadge.svelte index 507e74ba86..ecc8e07b11 100644 --- a/frontend/src/lib/components/flows/propPicker/OutputBadge.svelte +++ b/frontend/src/lib/components/flows/propPicker/OutputBadge.svelte @@ -1,7 +1,6 @@ diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index d4f7fa66ab..7e8283e406 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -66,7 +66,6 @@ import { sendUserToast } from '$lib/toast' import { currentWorkspaceRootId, workspaceRootId } from './sessionScope.svelte' import { page } from '$app/state' - import { base } from '$app/paths' import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte' import { dateBucket, @@ -119,7 +118,7 @@ // Only highlight the active session while the sessions page is open — elsewhere // `currentSessionId` lingers but no row should appear selected. - const onSessionsPage = $derived(page.url.pathname.startsWith(`${base}/sessions`)) + const onSessionsPage = $derived(page.url.pathname.startsWith('/sessions')) const sessionActive = $derived(onSessionsPage) interface Props { diff --git a/frontend/src/lib/components/sessions/SessionsBetaBanner.svelte b/frontend/src/lib/components/sessions/SessionsBetaBanner.svelte index 86b6ba030a..0d523a4e65 100644 --- a/frontend/src/lib/components/sessions/SessionsBetaBanner.svelte +++ b/frontend/src/lib/components/sessions/SessionsBetaBanner.svelte @@ -1,5 +1,4 @@ @@ -17,7 +16,7 @@ 1. Choose a trigger script using the script picker that will poll for new events regularly script picker @@ -26,7 +25,7 @@ 2. Add logic to process each new event create action diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 3c0982cf9b..141e67fd1b 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -35,7 +35,6 @@ } from '$lib/gen' import { emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils' import { useEditRights } from '$lib/operatorWriteRights' - import { base } from '$lib/base' import Section from '$lib/components/Section.svelte' import { List, Loader2, AlertTriangle } from 'lucide-svelte' import autosize from '$lib/autosize' @@ -655,7 +654,7 @@ variant="default" startIcon={{ icon: List }} disabled={!allowSchedule || pathError != '' || emptyString(script_path)} - href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`} + href={`/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`} > View runs diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte index 5ae952f03e..959b666363 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte @@ -64,7 +64,6 @@ import TextInput from '../text_input/TextInput.svelte' import { jobManager } from '$lib/services/JobManager' import Alert from '../common/alert/Alert.svelte' - import { base } from '$lib/base' import Label from '../Label.svelte' import ForkDatatableSection from './ForkDatatableSection.svelte' import Select from '../select/Select.svelte' @@ -1106,7 +1105,7 @@ disabled={forkCreationLoading} variant="default" size="sm" - href="{base}/user/workspaces">← Back to workspaces← Back to workspaces {/if} {#if !forkCreationLoading} diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte index f6929e0779..a01318d916 100644 --- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte @@ -91,7 +91,6 @@ import type { GetSettingsResponse } from '$lib/gen' import { enterpriseLicense, superadmin, userWorkspaces, workspaceStore } from '$lib/stores' - import { base } from '$app/paths' import Toggle from '../Toggle.svelte' import { sendUserToast } from '$lib/toast' import ExploreAssetButton from '../ExploreAssetButton.svelte' @@ -653,7 +652,7 @@ {/if} {#if ducklakeSavedSettings.ducklakes.find((d) => d.name === ducklake.name)?.maintenance?.enabled} ) { diff --git a/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts b/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts index 3e11ec2b9d..94c21cf591 100644 --- a/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts +++ b/frontend/src/lib/components/workspaceSettings/supabaseOauth.svelte.ts @@ -1,9 +1,8 @@ import { fromStore } from 'svelte/store' -import { base } from '$lib/base' import { oauthStore } from '$lib/stores' const OAUTH_WINDOW = 'windmill_supabase_oauth' -const CONNECT_URL = `${base}/api/oauth/connect/supabase_wizard` +const CONNECT_URL = '/api/oauth/connect/supabase_wizard' /** * The Supabase authorization leg, driven from a popup. diff --git a/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts b/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts index 5040b5ab38..ee91a0ba3c 100644 --- a/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts +++ b/frontend/src/lib/components/workspaceSettings/supabaseProvisioning.ts @@ -7,7 +7,6 @@ */ import { DEFAULT_SSLMODE } from '$lib/utils/postgresConnectionString' -import { base } from '$lib/base' import { oauthStore } from '$lib/stores' import { get } from 'svelte/store' @@ -90,19 +89,19 @@ export function supabaseErrorMessage(body: string): string { } export async function listSupabaseOrgs(token: string): Promise { - const res = await fetch(`${base}/api/oauth/list_supabase_orgs`, { headers: headers(token) }) + const res = await fetch('/api/oauth/list_supabase_orgs', { headers: headers(token) }) return unwrap(res, 'Could not list your Supabase organizations') } export async function listSupabaseProjects(token: string): Promise { - const res = await fetch(`${base}/api/oauth/list_supabase`, { headers: headers(token) }) + const res = await fetch('/api/oauth/list_supabase', { headers: headers(token) }) return unwrap(res, 'Could not list your Supabase projects') } /** Plan of one organization, which the list endpoint does not carry. */ export async function getSupabaseOrgPlan(token: string, slug: string): Promise { try { - const res = await fetch(`${base}/api/oauth/get_supabase_org/${slug}`, { + const res = await fetch(`/api/oauth/get_supabase_org/${slug}`, { headers: headers(token) }) if (!res.ok) return undefined @@ -132,7 +131,7 @@ export async function createSupabaseProject( token: string, args: { name: string; organizationSlug: string; region: string; dbPass: string } ): Promise { - const res = await fetch(`${base}/api/oauth/create_supabase_project`, { + const res = await fetch('/api/oauth/create_supabase_project', { method: 'POST', headers: headers(token), body: JSON.stringify({ @@ -185,7 +184,7 @@ export async function waitUntilSupabaseHealthy( * that landed on another one, and the resulting resource never connects. */ export async function getSupabasePooler(token: string, projectId: string): Promise { - const res = await fetch(`${base}/api/oauth/get_supabase_pooler/${projectId}`, { + const res = await fetch(`/api/oauth/get_supabase_pooler/${projectId}`, { headers: headers(token) }) const configs: SupabasePooler[] = await unwrap(res, 'Could not read the connection details') diff --git a/frontend/src/lib/draftAddRedirect.ts b/frontend/src/lib/draftAddRedirect.ts index 242893f782..2c027ac1da 100644 --- a/frontend/src/lib/draftAddRedirect.ts +++ b/frontend/src/lib/draftAddRedirect.ts @@ -1,5 +1,4 @@ import { redirect } from '@sveltejs/kit' -import { base } from '$app/paths' import { mintDraftPath } from '$lib/mintDraftPath' /** @@ -8,7 +7,7 @@ import { mintDraftPath } from '$lib/mintDraftPath' * * Mints a fresh `u//draft_` path (via the shared `mintDraftPath`, * so the SDK builder wrappers stay in lockstep) and 307s to - * `{base}/{editPrefix}/?new_draft=true&`. `new_draft=true` + * `/{editPrefix}/?new_draft=true&`. `new_draft=true` * tells the edit route to seed an empty editor instead of 404-ing. The hash is * carried over too — fork / handler-template buttons encode a payload into it * that the edit route's `new_draft` branch consumes. @@ -23,6 +22,6 @@ export function makeDraftAddLoad(editPrefix: string) { // points at the PREVIOUS page, but every hash-payload producer arrives // as a full page load, so the hash is correct. const hash = typeof window !== 'undefined' ? window.location.hash : '' - redirect(307, `${base}/${editPrefix}/${path}?${params.toString()}${hash}`) + redirect(307, `/${editPrefix}/${path}?${params.toString()}${hash}`) } } diff --git a/frontend/src/lib/githubApp.ts b/frontend/src/lib/githubApp.ts index 666734546e..0c19cd8de2 100644 --- a/frontend/src/lib/githubApp.ts +++ b/frontend/src/lib/githubApp.ts @@ -4,7 +4,6 @@ import { type GetGhesConfigResponse } from '$lib/gen' import { sendUserToast } from '$lib/toast' -import { base } from '$lib/base' export interface GitHubAppState { loadingGithubInstallations: boolean @@ -137,7 +136,7 @@ export async function loadGithubInstallations( const stateParam = encodeURIComponent( JSON.stringify({ workspace_id: currentWorkspace, - base_url: window.location.origin + base + base_url: window.location.origin }) ) diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts index f499ddf1f7..d7f503b4eb 100644 --- a/frontend/src/lib/navigation.test.ts +++ b/frontend/src/lib/navigation.test.ts @@ -2,11 +2,10 @@ import { describe, it, expect, vi } from 'vitest' const goto = vi.hoisted(() => vi.fn(async (_path: string) => {})) vi.mock('$app/navigation', () => ({ goto: (path: string) => goto(path) })) -vi.mock('$app/paths', () => ({ base: '' })) import { buildFilterUrl, setQuery } from './navigation' -// Parse the (un-based) app path the builder returns against a dummy origin so we can +// Parse the app path the builder returns against a dummy origin so we can // assert on pathname / searchParams / hash without caring about ordering. function parse(appPath: string): URL { return new URL(appPath, 'http://x') diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index db8b9f3a80..164b9402c1 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,23 +1,14 @@ -import { goto as svelteGoto } from '$app/navigation' -import { base as svelteBase } from '$app/paths' +import { goto } from '$app/navigation' import { serializeParam } from '$lib/utils/serializeParam' -export function goto(path: string, options = {}) { - if (svelteBase == '' || path.startsWith('?')) { - return svelteGoto(path, options) - } else { - const fullPath = path.startsWith(svelteBase) ? path : `${svelteBase}${path}` - return svelteGoto(fullPath, options) - } -} +export { goto } /** * Build an in-app deep-link to `pathname` with query-param filters, encoded exactly * as the pages write them (via `serializeParam`) so `useUrlSyncedFilterInstance` * round-trips them back into filter state. Nullish/empty values are dropped; when * `validKeys` is provided, unknown keys are dropped too (structured output guarantees - * shape, not truth). Returns an un-prefixed app path — pass it to `goto`, which adds - * the SvelteKit base. + * shape, not truth). Returns an app path to pass to `goto`. */ export function buildFilterUrl( pathname: string, diff --git a/frontend/src/lib/remoteDeploy.ts b/frontend/src/lib/remoteDeploy.ts index ad887504fb..dbac372f94 100644 --- a/frontend/src/lib/remoteDeploy.ts +++ b/frontend/src/lib/remoteDeploy.ts @@ -1,4 +1,3 @@ -import { base } from '$lib/base' import type { RemoteDeployConnection, RemoteDeployTarget } from '$lib/gen' import { randomSecret } from '$lib/utils/uuid' @@ -67,7 +66,7 @@ export function remoteDeployAuthorizeUrl( localStorage.setItem(PENDING_PREFIX + state, JSON.stringify(pending)) const params = new URLSearchParams({ workspace: target.workspace_id, - callback: `${window.location.origin}${base}${CALLBACK_PATH}`, + callback: `${window.location.origin}${CALLBACK_PATH}`, state }) return `${target.base_url}/user/remote_deploy_authorize?${params}` diff --git a/frontend/src/lib/scripts.ts b/frontend/src/lib/scripts.ts index 6538db29e7..f9f1fa775e 100644 --- a/frontend/src/lib/scripts.ts +++ b/frontend/src/lib/scripts.ts @@ -1,5 +1,4 @@ import { get } from 'svelte/store' -import { base } from '$lib/base' import type { Schema, SupportedLanguage } from './common' import { FlowService, type Script, ScriptService, ScheduleService } from './gen' import { hubBaseUrlStore, workspaceStore } from './stores' @@ -136,7 +135,7 @@ export function scriptPathToHref(path: string, hubBaseUrl: string): string { if (path.startsWith('hub/')) { return hubBaseUrl + '/from_version/' + path.substring(4) } else { - return `${base}/scripts/get/${path}?workspace=${get(workspaceStore)}` + return `/scripts/get/${path}?workspace=${get(workspaceStore)}` } } @@ -146,7 +145,7 @@ export function flowPathToHref(path: string, hubBaseUrl: string = get(hubBaseUrl return hubFlowId ? `${hubBaseUrl}/flows/${hubFlowId}` : hubBaseUrl } - return `${base}/flows/get/${path}?workspace=${get(workspaceStore)}` + return `/flows/get/${path}?workspace=${get(workspaceStore)}` } const scriptLanguagesArray: [SupportedLanguage | 'docker' | 'bunnative', string][] = [ diff --git a/frontend/src/lib/utils/editInFork.ts b/frontend/src/lib/utils/editInFork.ts index 67eebcf7eb..d43c3644c9 100644 --- a/frontend/src/lib/utils/editInFork.ts +++ b/frontend/src/lib/utils/editInFork.ts @@ -1,4 +1,3 @@ -import { base } from '$lib/base' import { get } from 'svelte/store' import { userStore, @@ -75,13 +74,13 @@ export function canCreateFork(user: UserExt | undefined): boolean { function editPathFor(itemType: ItemType, itemPath: string): string { switch (itemType) { case 'script': - return `${base}/scripts/edit/${itemPath}` + return `/scripts/edit/${itemPath}` case 'flow': - return `${base}/flows/edit/${itemPath}` + return `/flows/edit/${itemPath}` case 'app': - return `${base}/apps/edit/${itemPath}` + return `/apps/edit/${itemPath}` case 'raw_app': - return `${base}/apps_raw/edit/${itemPath}` + return `/apps_raw/edit/${itemPath}` } } @@ -101,7 +100,7 @@ export function buildForkEditUrl( * workspace the page loads in, so a caller acting on another one names it. */ export function forkWorkspaceUrl(itemType: ItemType, itemPath: string, workspace?: string): string { const from = workspace ? `&workspace=${encodeURIComponent(workspace)}` : '' - return `${base}/user/fork_workspace?rd=${encodeURIComponent(editPathFor(itemType, itemPath))}${from}` + return `/user/fork_workspace?rd=${encodeURIComponent(editPathFor(itemType, itemPath))}${from}` } /** The item's editor in the dev workspace — the target `buildForkEditUrl` produces when a dev exists. */ diff --git a/frontend/src/lib/workspaceCreation.ts b/frontend/src/lib/workspaceCreation.ts index 34c1644462..0a31d66a96 100644 --- a/frontend/src/lib/workspaceCreation.ts +++ b/frontend/src/lib/workspaceCreation.ts @@ -13,7 +13,6 @@ import { SettingService, UserService, WorkspaceService } from '$lib/gen' import { usersWorkspaceStore } from '$lib/stores' import { switchWorkspace } from '$lib/storeUtils' import { isCloudHosted } from '$lib/cloud' -import { base } from '$lib/base' import { WORKSPACE_NAME_MAX_LENGTH } from '$lib/utils/workspaceId' /** @@ -30,7 +29,7 @@ import { WORKSPACE_NAME_MAX_LENGTH } from '$lib/utils/workspaceId' export async function canCreateWorkspace(isSuperadmin: boolean): Promise { if (isSuperadmin || isCloudHosted()) return true try { - const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin') + const r = await fetch('/api/workspaces/create_workspace_require_superadmin') if ((await r.text()) != 'true') return true return !!(await UserService.globalWhoami()).super_admin } catch { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 10a4d2d0ef..5edf490220 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -102,7 +102,6 @@ import InstanceUiSync from '$lib/components/InstanceUiSync.svelte' import { onDestroy, setContext, untrack } from 'svelte' import { cubicOut } from 'svelte/easing' - import { base } from '$app/paths' import { Menubar, Tooltip } from '$lib/components/meltComponents' import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte' @@ -113,7 +112,7 @@ import { isPageItemListPath, parsePreviewItemRoute, - stripBase + barePath } from '$lib/components/sessions/previewPaths' import { rememberNavRoute } from '$lib/components/sessions/sessionSwitch.svelte' import { sessionState } from '$lib/components/sessions/sessionState.svelte' @@ -303,7 +302,7 @@ // An operator without the opt-in (`gate.ts`) is refused by /sessions, so their sidebar is not // handed over to a session list the page behind it declines, and they keep the docked chat. const sessionsAllowed = $derived(sessionsAllowedFor($userStore?.operator)) - let sessionMode = $derived(page.url.pathname.startsWith(base + '/sessions') && sessionsAllowed) + let sessionMode = $derived(page.url.pathname.startsWith('/sessions') && sessionsAllowed) // Session mode points the bottom settings entry at the open session's own // workspace. An unsent draft hasn't committed one yet, so it falls back to // the family root. @@ -468,7 +467,7 @@ } // A list page mounts in process too, where its rows open as tabs of their own. const listUrl = navigation.to?.url - if (listUrl && isPageItemListPath(stripBase(listUrl.pathname))) { + if (listUrl && isPageItemListPath(barePath(listUrl.pathname))) { navigation.cancel() const u = new URL(listUrl.href) u.searchParams.delete('nomenubar') @@ -719,7 +718,7 @@ // Remember the last navigation-mode route so exiting session mode returns // the user where they were rather than to the home page. const to = n.to?.url - if (to && !to.pathname.startsWith(base + '/sessions')) { + if (to && !to.pathname.startsWith('/sessions')) { rememberNavRoute(to.pathname + to.search) } }) @@ -734,7 +733,7 @@ } } - let devOnly = $derived(page.url.pathname.startsWith(base + '/scripts/dev')) + let devOnly = $derived(page.url.pathname.startsWith('/scripts/dev')) async function loadDefaultScripts(workspace: string, user: UserExt | undefined) { if (!user?.operator) { @@ -1051,7 +1050,7 @@

diff --git a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte index 3070345ddf..ceb37c0a10 100644 --- a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte @@ -2,7 +2,6 @@ import { page } from '$app/state' import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte' import { resource } from 'runed' - import { base } from '$lib/base' import { goto } from '$lib/navigation' import { copilotInfo } from '$lib/aiStore' import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte' @@ -60,7 +59,7 @@ } async function remove() { - if (ws && (await deleteAgent(ws, path))) await goto(`${base}/?kind=agent`) + if (ws && (await deleteAgent(ws, path))) await goto('/?kind=agent') } // The workspace's, so a path change never waits on it and a menu never outlives its path. @@ -122,7 +121,7 @@ ? async (newPath) => { // A rename moves the page; a summary saved in place has to be read again, since // what is shown here came from the load this page keys on. - if (newPath !== path) await goto(`${base}/agents/get/${newPath}`) + if (newPath !== path) await goto(`/agents/get/${newPath}`) else reloaded++ } : undefined} @@ -146,7 +145,7 @@ variant: 'accent', unifiedSize: 'md', startIcon: Pen, - href: `${base}/agents/edit/${path}` + href: `/agents/edit/${path}` } } ] diff --git a/frontend/src/routes/(root)/(logged)/apps/get_raw/[version]/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get_raw/[version]/[...path]/+page.svelte index dbba9ce790..edc0c2d918 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get_raw/[version]/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get_raw/[version]/[...path]/+page.svelte @@ -1,6 +1,5 @@ diff --git a/frontend/src/routes/(root)/(logged)/concurrency_groups/+page.svelte b/frontend/src/routes/(root)/(logged)/concurrency_groups/+page.svelte index 2272449e0f..9e58c633e7 100644 --- a/frontend/src/routes/(root)/(logged)/concurrency_groups/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/concurrency_groups/+page.svelte @@ -1,7 +1,6 @@ {#if workspaceSettingsInitialized === false} - {#if fromWorkspaceSettings} - -
-

Double check the S3 resource fields and try again.

-
-
- {:else if s3ResourcePath} - -
-

- Double check the resource fields and that its object storage is reachable, then try again. -

-
-
- {:else} - -
- - {/if} +
+ {#if fromWorkspaceSettings} + +
+

Double check the S3 resource fields and try again.

+
+
+ {:else if s3ResourcePath} + +
+

+ Double check the resource fields and that its object storage is reachable, then try + again. +

+
+
+ {:else} + +
+

+ The workspace needs to be connected to an S3 storage to use this feature. You can configure it here. +

+
+
+ {/if} +
{:else} {#if fileListUnavailable == true} {#if replaceUnauthorizedWarning} {@render replaceUnauthorizedWarning()} {:else} -
+

You don't have access to the S3 bucket resource and your administrator has restricted @@ -1097,28 +1214,25 @@

{/if} {/if} -
+ {#if !fileListUnavailable} -
+ {#if !rootPath} -
- +
{/if} {#if displayedFileKeys.length === 0} {#if fileListLoading} -
-
- Loading content -
+
+ + Loading...
{:else} -
+
{#if filter.trim() !== ''} No files starting with "{filter.trim()}" {:else} @@ -1130,19 +1244,19 @@ can remove every match. Without this the only control that could resume the listing would be hidden behind the empty state. -->
-
{/if} {/if} {:else} -
+
{#snippet header()}{/snippet} {#snippet footer()}{/snippet} @@ -1154,199 +1268,249 @@ {@const load_more_prefix = is_load_more ? item_key.slice(0, -1) : ''} {@const file_info = allFilesByKey[item_key]} -
+
{#if is_load_more} {@const loadMoreNesting = nestingLevelOf(load_more_prefix + 'x') - 2 * rootPathNestingLevel} {@const loadingMore = folderState[load_more_prefix]?.loading === true} - - -
!loadingMore && loadMore(load_more_prefix)} - class={twMerge( - 'flex flex-row h-full text-xs items-center justify-start text-secondary', - loadingMore ? 'cursor-default' : 'cursor-pointer' - )} + disabled={loadingMore} + class="w-full text-xs font-normal flex gap-2 items-center h-8 pr-1 text-secondary hover:bg-surface-hover cursor-pointer disabled:cursor-default disabled:hover:bg-transparent" + style={treeRowIndent(loadMoreNesting)} > -
- -
- {#if loadingMore} - - {:else} - - {/if} -
-
Load more
-
-
+ + {#if loadingMore} + + {:else} + + {/if} + Load more + {:else if file_info} {@const nestingLevel = file_info.nestingLevel - 2 * rootPathNestingLevel} - - -
selectItem(index)} + title={file_info.full_key.slice(rootPath.length)} class={twMerge( - 'flex flex-row h-full font-semibold text-xs items-center justify-start', - selectedFileKey !== undefined && selectedFileKey.s3 === file_info.full_key - ? 'bg-surface-hover' - : '' + 'w-full text-xs font-normal text-primary flex gap-2 items-center h-8 pr-1 cursor-pointer', + isSelected ? 'bg-surface-secondary' : 'hover:bg-surface-hover' )} + style={treeRowIndent(nestingLevel)} > -
- {#if file_info.type === 'folder'} - {#if folderState[file_info.full_key]?.loading} - - {:else if file_info.collapsed}{:else}{/if} -
- - {#if file_info.display_name === ''} - (empty name) - {:else}{file_info.display_name}{/if} - {#if !lazyMode} - ({file_info.count}{count % 1000 === 0 && - lastKeyFolders[file_info.nestingLevel / 2] === file_info.display_name - ? '+' - : ''} item{file_info.count === 1 ? '' : 's'}) - {/if} -
+ {#if isFolder} + {#if folderState[file_info.full_key]?.loading} + {:else} - -
- {file_info.display_name} -
+ {/if} -
-
+ {:else} + + {/if} + + + + {#if file_info.display_name === ''} + (empty name) + {:else}{file_info.display_name}{/if} + + + {#if isFolder && !lazyMode} + + {file_info.count}{count % 1000 === 0 && + lastKeyFolders[file_info.nestingLevel / 2] === file_info.display_name + ? '+' + : ''} + + {/if} + {/if}
{/snippet}
{#if fileListLoading === true} -
- Loading content +
+ Loading...
{:else if lazyMode}
{displayedCount} item{displayedCount === 1 ? '' : 's'} shown
{:else} -
+
{displayedCount}{flatHasMore ? '+' : ''} {displayedCount !== count ? 'filtered ' : ''}items (including inside folders)
{#if flatHasMore} - {/if} {/if}
{/if} -
+ {/if} -
- {#if fileMetadata === undefined} -
- {#if fileInfoLoading} -
- {:else if fileListUnavailable} -
- {:else} -
- {/if} -
- {:else} -
-
-

- {((p) => (p.startsWith(rootPath) ? p.slice(rootPath.length) : p))( - fileMetadata.fileKey - )} -

- {#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)} -
- {#if !hideS3SpecificDetails} - {@const downloadApiPath = `/w/${ws}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}${s3ResourcePath ? `&s3_resource_path=${encodeURIComponent(s3ResourcePath)}` : ''}`} - {@const downloadName = - fileMetadata?.fileKey.split('/').pop() ?? 'unnamed_download.file'} - {#if shouldDownloadViaClient()} -
+ {:else if !csvOptions} +
+ {#if filePreview.contentType === 'Csv'} + Previewing a {filePreview.contentType?.toLowerCase()} file. Separator character: +
+ +
+ Header row: +
+ +
+ {:else} + Previewing a {filePreview.contentType?.toLowerCase()} file. + {/if} +
+ {/if}
{#if !emptyString(filePreview.contentPreview)}{filePreview.contentPreview}{:else if filePreview.contentType !== undefined}Preview impossible.{/if}
diff --git a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts index 21371aa08b..ae3a82e97c 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/utils.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/utils.ts @@ -41,6 +41,8 @@ export type ColumnDef = { children?: ColumnDef[] // DBExplorer ignored?: boolean + /** False when the column cannot be filtered on, e.g. a nested value. */ + filterable?: boolean hideInsert?: boolean editable?: boolean overrideDefaultValue?: boolean diff --git a/frontend/src/lib/components/dbTableFilters.ts b/frontend/src/lib/components/dbTableFilters.ts index 9d9c83e6b0..c3266b3ac7 100644 --- a/frontend/src/lib/components/dbTableFilters.ts +++ b/frontend/src/lib/components/dbTableFilters.ts @@ -52,7 +52,7 @@ export function buildDbTableFilterSchema(columns: ColumnDef[]): DbTableFilterSch const columnOfKey: Record = {} const keyOfColumn: Record = {} for (const col of columns) { - if (!col.field) continue + if (!col.field || col.filterable === false) continue let base = col.field.replace(/\W+/g, '_') || 'column' if (base === FREE_TEXT_KEY) base = `${base}col` let key = base diff --git a/frontend/src/lib/components/s3TableOps.test.ts b/frontend/src/lib/components/s3TableOps.test.ts new file mode 100644 index 0000000000..f32ce47c56 --- /dev/null +++ b/frontend/src/lib/components/s3TableOps.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { datatypeOf, jsonTableRows, serverTableFormat } from './s3TableOps' +import { dbColumnKind } from './dbTableFilters' + +describe('serverTableFormat', () => { + it('pages delimited text and newline-delimited JSON through the csv reader', () => { + expect(serverTableFormat('a/b.parquet')).toBe('parquet') + for (const key of ['a.csv', 'a.TSV', 'a.jsonl', 'a.ndjson']) { + expect(serverTableFormat(key)).toBe('csv') + } + }) + + it('leaves plain JSON to the browser', () => { + expect(serverTableFormat('a.json')).toBeUndefined() + expect(serverTableFormat('a.txt')).toBeUndefined() + }) +}) + +describe('datatypeOf', () => { + it('maps Arrow types to the kinds the grid filters by', () => { + expect(dbColumnKind(datatypeOf('Int64'))).toBe('number') + expect(dbColumnKind(datatypeOf('UInt8'))).toBe('number') + expect(dbColumnKind(datatypeOf('Float64'))).toBe('number') + expect(dbColumnKind(datatypeOf('Decimal128(21, 1)'))).toBe('number') + expect(dbColumnKind(datatypeOf('Boolean'))).toBe('boolean') + expect(dbColumnKind(datatypeOf('Utf8View'))).toBe('text') + }) + + it('names a nested type without its field list', () => { + expect(datatypeOf('List(Field { name: "item", data_type: Utf8 })')).toBe('list') + expect(datatypeOf('Timestamp(Microsecond, None)')).toBe('timestamp') + }) +}) + +describe('jsonTableRows', () => { + it('keeps objects and wraps other elements in a value column', () => { + expect(jsonTableRows([{ a: 1 }, 2, null])).toEqual([{ a: 1 }, { value: 2 }, { value: null }]) + }) + + it('has no table for an empty array or a non-array', () => { + expect(jsonTableRows([])).toBeUndefined() + expect(jsonTableRows({ a: 1 })).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/s3TableOps.ts b/frontend/src/lib/components/s3TableOps.ts new file mode 100644 index 0000000000..f5d7bd758b --- /dev/null +++ b/frontend/src/lib/components/s3TableOps.ts @@ -0,0 +1,170 @@ +import { HelpersService, OpenAPI } from '$lib/gen' +import { getHeaders } from '$lib/gen/core/request' +import { ColumnIdentity, type ColumnDef } from './apps/components/display/dbtable/utils' +import type { IDbTableOps } from './dbOps' + +/** Files the server can page through as a table, without reading them whole. */ +export function serverTableFormat(fileKey: string): 'parquet' | 'csv' | undefined { + const key = fileKey.toLowerCase() + if (key.endsWith('.parquet')) return 'parquet' + if (/\.(csv|tsv|jsonl|ndjson)$/.test(key)) return 'csv' + return undefined +} + +export type S3TableSource = { + workspace: string + fileKey: string + storage?: string + s3ResourcePath?: string + csvSeparator?: string + csvHasHeader?: boolean +} + +type Chunk = { + columns: string[] + column_types?: string[] + rows: Record[] + csv_separator?: string +} + +/** An Arrow type as the SQL type name the grid filters and formats by. */ +export function datatypeOf(arrowType: string | undefined): string { + const t = arrowType ?? '' + if (/^U?Int\d+$/.test(t)) return 'bigint' + if (/^Float\d+$/.test(t)) return 'double' + if (/^Decimal/.test(t)) return 'decimal' + if (t === 'Boolean') return 'boolean' + if (/^(Large)?Utf8/.test(t)) return 'text' + // A nested type spells out its fields, e.g. `List(Field { name: "item", … })`. + return t.split('(')[0].toLowerCase() || 'text' +} + +/** Read-only table ops over an object storage file, paged, searched, filtered and sorted + * by the server, with the CSV separator it read the file with (guessed when none is given). + */ +export async function s3TableOps( + source: S3TableSource +): Promise<{ ops: IDbTableOps; csvSeparator?: string }> { + const format = serverTableFormat(source.fileKey) + if (!format) throw new Error(`${source.fileKey} cannot be read as a table`) + const common = { + workspace: source.workspace, + path: source.fileKey, + storage: source.storage, + s3ResourcePath: source.s3ResourcePath + } + const csvOptions: { csvSeparator?: string; csvHasHeader?: boolean } = + format === 'csv' ? { csvSeparator: source.csvSeparator, csvHasHeader: source.csvHasHeader } : {} + const filtersParam = (columnFilters: Record | undefined) => + columnFilters && Object.keys(columnFilters).length ? JSON.stringify(columnFilters) : undefined + const searchParam = (quicksearch: string) => quicksearch.trim() || undefined + + async function loadChunk(params: { + offset: number + limit: number + quicksearch?: string + columnFilters?: Record + orderBy?: string + isDesc?: boolean + }): Promise { + const data = { + ...common, + ...csvOptions, + offset: params.offset, + limit: params.limit, + searchTerm: searchParam(params.quicksearch ?? ''), + filters: filtersParam(params.columnFilters), + sortCol: params.orderBy, + sortDesc: params.isDesc + } + return (await (format === 'parquet' + ? HelpersService.loadParquetPreview(data) + : HelpersService.loadCsvPreview(data))) as Chunk + } + + const head = await loadChunk({ offset: 0, limit: 0 }) + // Later reads reuse the separator the server guessed, rather than each guessing again. + if (format === 'csv') csvOptions.csvSeparator ??= head.csv_separator + const colDefs: ColumnDef[] = head.columns.map((field, i) => ({ + field, + datatype: datatypeOf(head.column_types?.[i]), + // The server cannot read a struct, map or union as text to filter it. + filterable: !/^(Struct|Map|Union)/.test(head.column_types?.[i] ?? ""), + defaultvalue: '', + isprimarykey: false, + isidentity: ColumnIdentity.No, + isnullable: 'YES', + isenum: false + })) + + const ops: IDbTableOps = { + dbType: 'duckdb', + tableKey: source.fileKey.split('/').pop() ?? source.fileKey, + colDefs, + getCount: async ({ quicksearch, columnFilters }) => { + const res = await HelpersService.loadTableRowCount({ + ...common, + ...csvOptions, + searchTerm: searchParam(quicksearch), + filters: filtersParam(columnFilters) + }) + return res.count ?? 0 + }, + getRows: async ({ + offset, + limit, + quicksearch, + columnFilters, + order_by, + is_desc, + explicitSort + }) => + ( + await loadChunk({ + offset, + limit, + quicksearch, + columnFilters, + // Unsorted, a file keeps its own row order. + orderBy: explicitSort ? order_by : undefined, + isDesc: is_desc + }) + ).rows + } + return { ops, csvSeparator: head.csv_separator } +} + +/** Plain JSON cannot be paged by the server, so only a file this small is read whole into + * the browser to be shown as a table. */ +export const JSON_TABLE_MAX_BYTES = 10 * 1024 * 1024 + +export function isJsonFile(fileKey: string): boolean { + return fileKey.toLowerCase().endsWith('.json') +} + +/** Rows of a JSON array, an element that is not an object becoming a one-column row; + * undefined for anything else. */ +export function jsonTableRows(value: unknown): Record[] | undefined { + if (!Array.isArray(value) || value.length === 0) return undefined + return value.map((v) => + v !== null && typeof v === 'object' && !Array.isArray(v) + ? (v as Record) + : { value: v } + ) +} + +export async function fetchJsonTableRows( + source: S3TableSource +): Promise[] | undefined> { + const query = new URLSearchParams({ file_key: source.fileKey }) + if (source.storage) query.set('storage', source.storage) + if (source.s3ResourcePath) query.set('s3_resource_path', source.s3ResourcePath) + const apiPath = `/w/${source.workspace}/job_helpers/download_s3_file?${query}` + const headers = await getHeaders(OpenAPI, { method: 'GET', url: apiPath }) + const response = await fetch(`${OpenAPI.BASE}${apiPath}`, { + headers, + credentials: OpenAPI.CREDENTIALS + }) + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) + return jsonTableRows(await response.json()) +} From 478eef735222e60de8ed6086f6b036e2346f1de4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 09:56:48 +0200 Subject: [PATCH 151/174] fix: resolve $var: references in mcp resource headers (#11679) * fix: resolve $var: references in mcp resource headers Co-Authored-By: Claude Opus 5.5 * fix: require variables:read scope to resolve mcp header variables Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/tests/mcp_header_var_refs.rs | 110 ++++++++++++++++++++++++ backend/windmill-api/src/mcp_tools.rs | 28 +++--- backend/windmill-mcp/src/client/mod.rs | 3 +- backend/windmill-worker/src/ai/utils.rs | 14 ++- 4 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 backend/tests/mcp_header_var_refs.rs diff --git a/backend/tests/mcp_header_var_refs.rs b/backend/tests/mcp_header_var_refs.rs new file mode 100644 index 0000000000..158bc71a5f --- /dev/null +++ b/backend/tests/mcp_header_var_refs.rs @@ -0,0 +1,110 @@ +//! An MCP resource header whose value is a `$var:` reference reaches the MCP +//! server as the variable's value, and only for a caller allowed to read it. +#![cfg(feature = "mcp")] + +use sqlx::{Pool, Postgres}; +use tokio::io::AsyncReadExt; +use windmill_common::client::AuthedClient; +use windmill_test_utils::*; +use windmill_worker::{load_mcp_tools, McpResourceConfig}; + +const SECRET_VALUE: &str = "S3CRET-MCP-HEADER-VALUE"; + +/// A secret variable and an MCP resource at `url` sending it as a header. +async fn seed(db: &Pool, url: &str) -> anyhow::Result<()> { + let mc = windmill_common::variables::build_crypt(db, "test-workspace").await?; + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) + VALUES ('test-workspace', 'u/test-user/mcp_header', $1, true, '', '{}')", + ) + .bind(windmill_common::variables::encrypt(&mc, SECRET_VALUE)) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/mcp', $1, 'mcp', '{}', 'test-user')", + ) + .bind(serde_json::json!({ + "name": "mcp", + "url": url, + "headers": { "X-Api-Key": "$var:u/test-user/mcp_header", "X-Plain": "plain" } + })) + .execute(db) + .await?; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_mcp_header_var_ref_is_resolved(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + std::env::set_var("ALLOW_PRIVATE_MCP_SERVER_URLS", "true"); + + // Not an MCP server: it captures the first request and hangs up, so loading + // fails at the connect step after the headers were sent. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let mcp_port = listener.local_addr()?.port(); + let first_request = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await?; + let mut buf = vec![0u8; 16 * 1024]; + let n = socket.read(&mut buf).await?; + anyhow::Ok(String::from_utf8_lossy(&buf[..n]).to_lowercase()) + }); + seed(&db, &format!("http://127.0.0.1:{mcp_port}/mcp")).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 config = vec![McpResourceConfig { + resource_path: "$res:u/test-user/mcp".to_string(), + include_tools: None, + exclude_tools: None, + }]; + let _ = load_mcp_tools(&db, "test-workspace", config, &client).await; + + let request = first_request.await??; + assert!( + request.contains(&format!("x-api-key: {}", SECRET_VALUE.to_lowercase())), + "the header must carry the variable's value, not the reference: {request}" + ); + assert!(request.contains("x-plain: plain"), "{request}"); + + Ok(()) +} + +/// A token scoped to the resource alone must not have the API read the +/// variable a header points at. +#[sqlx::test(fixtures("base"))] +async fn test_mcp_header_var_ref_requires_variable_scope(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + seed(&db, "https://mcp.invalid.windmill.test").await?; + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) + VALUES (encode(sha256('RES_ONLY_TOKEN'::bytea), 'hex'), 'RES_ONLY_T', 'RES_ONLY_TOKEN', + 'test@windmill.dev', 'resources only', false, '{resources:read}')", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{}/api/w/test-workspace/resources/mcp_tools/u/test-user/mcp", + server.addr.port() + )) + .header("Authorization", "Bearer RES_ONLY_TOKEN") + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{body}"); + assert!( + body.contains("variables:read:u/test-user/mcp_header"), + "{body}" + ); + + Ok(()) +} diff --git a/backend/windmill-api/src/mcp_tools.rs b/backend/windmill-api/src/mcp_tools.rs index e1931873a9..1e07f374de 100644 --- a/backend/windmill-api/src/mcp_tools.rs +++ b/backend/windmill-api/src/mcp_tools.rs @@ -23,14 +23,12 @@ async fn with_deadline( what: &str, fut: impl std::future::Future>, ) -> Result { - tokio::time::timeout(MCP_DEADLINE, fut) - .await - .map_err(|_| { - Error::ExecutionErr(format!( - "MCP server did not answer within {}s ({what})", - MCP_DEADLINE.as_secs() - )) - })? + tokio::time::timeout(MCP_DEADLINE, fut).await.map_err(|_| { + Error::ExecutionErr(format!( + "MCP server did not answer within {}s ({what})", + MCP_DEADLINE.as_secs() + )) + })? } /// Connect to the MCP server described by the `mcp` resource at `path`. @@ -65,8 +63,9 @@ async fn connect_mcp_client( let resource_value = not_found_if_none(resource_value_o, "Resource", path)? .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", path)))?; - let mcp_resource = serde_json::from_str::(resource_value.0.get()) - .map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?; + let mut mcp_resource = + serde_json::from_str::(resource_value.0.get()) + .map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?; #[cfg(feature = "oauth2")] { @@ -126,6 +125,15 @@ 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) { + 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())); + *value = get_value_internal(&db_authed, w_id, var_path, false).await?; + } + } + windmill_mcp::McpClient::from_resource(mcp_resource, token) .await .map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e))) diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index 863d6b0355..5d13a40314 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -70,7 +70,8 @@ impl McpClient { headers.insert(name, value); } _ => { - tracing::warn!("Invalid header: {}={}", key, value); + // The value may be a resolved secret: keep it out of the logs. + tracing::warn!("Invalid header: {}", key); } } } diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index 7437184385..50bbcad58f 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -570,7 +570,7 @@ pub async fn load_mcp_tools( // is not allowed to read (resources:read:{path}). Reading through the raw // db pool here would bypass the authorization enforced by the regular MCP // tools API (get_mcp_tools) and act as a confused deputy. - let mcp_resource = client + let mut mcp_resource = client .get_resource_value::(path) .await .map_err(|e| { @@ -615,6 +615,18 @@ pub async fn load_mcp_tools( None }; + // 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:") { + *value = client.get_variable_value(var_path).await.map_err(|e| { + Error::internal_err(format!( + "Failed to resolve variable {} for header {} of MCP resource {}: {}", + var_path, name, resource_name, e + )) + })?; + } + } + // Create new MCP client for this execution tracing::debug!("Creating fresh MCP client for {}", resource_name); let mcp_conn = McpClient::from_resource(mcp_resource, token) From b347e792f85a110cc99ec13ab52459def8079195 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 10:47:52 +0200 Subject: [PATCH 152/174] make the packaged components resolvable by consumers (#11682) * fix: make the packaged components resolvable by consumers The svelte-package output of @windmill-labs/components imported the chat SDK from outside the package root, pointed `$lib/utils` at a directory with no index, and shipped an `import.meta.glob` on the `$lib` alias. Bundle the chat SDK entry point into the package, repair the directory specifier, make the glob relative, and fail packaging on any import a consumer cannot resolve. Co-Authored-By: Claude Opus 5.5 * fix: pin the chat sdk output root and widen the packaging checks Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- frontend/scripts/fix-svelte-module-imports.js | 77 ++++++++++++++++++- .../src/lib/components/mcp/providerIcon.ts | 5 +- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/frontend/scripts/fix-svelte-module-imports.js b/frontend/scripts/fix-svelte-module-imports.js index 9cec6265a0..fe4fde4959 100644 --- a/frontend/scripts/fix-svelte-module-imports.js +++ b/frontend/scripts/fix-svelte-module-imports.js @@ -16,9 +16,22 @@ // tarball. Copy the registry into the package and rewrite those imports // to point at it (mirrors how package-system-prompts.js bundles // `$system_prompts`). +// +// 3. Chat SDK: the `windmill-chat` alias (svelte.config.js) points at +// `chat-sdk/src/index.ts`, which escapes the package root the same way. +// Compile that entry point into the package and rewrite those imports. +// +// 4. `$lib/utils` from inside `utils/`: svelte-package turns it into `./`, +// which is the `utils/` directory (no index) rather than `utils.js`. +// Point a directory specifier with no index at its sibling module. +// +// A relative import that still escapes the package root, or an +// `import.meta.glob` left on an alias, fails the build: either one +// publishes fine and only breaks in the consumer's bundler. +import { execFileSync } from 'child_process' import { copyFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs' import { fileURLToPath } from 'url' -import { dirname, join, relative, resolve } from 'path' +import { dirname, join, relative, resolve, sep } from 'path' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -29,8 +42,37 @@ const oauthConnectDest = join(packageDir, 'oauth_connect.json') copyFileSync(oauthConnectSrc, oauthConnectDest) -const IMPORT_SPECIFIER_RE = /(\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.\.?\/[^'"]*\.(?:svelte|json))\2/g +const chatSdkSrc = resolve(__dirname, '..', '..', 'chat-sdk', 'src', 'index.ts') +const chatSdkDest = join(packageDir, 'chat-sdk') + +// File arguments make tsc ignore every tsconfig and emit only what the entry +// point reaches, which leaves out the React and AI SDK adapters. +execFileSync( + 'npx', + [ + 'tsc', + chatSdkSrc, + '--outDir', + chatSdkDest, + '--rootDir', + dirname(chatSdkSrc), + '--declaration', + '--target', + 'ES2021', + '--module', + 'ESNext', + '--moduleResolution', + 'bundler', + '--skipLibCheck' + ], + { stdio: 'inherit', cwd: resolve(__dirname, '..') } +) + +const IMPORT_SPECIFIER_RE = /(\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.\.?(?:\/[^'"]*)?)\2/g const OAUTH_CONNECT_RE = /^(\.\.\/)+backend\/oauth_connect\.json$/ +const CHAT_SDK_RE = /^(\.\.\/)+chat-sdk\/src\/index\.ts$/ +const DIRECTORY_RE = /(^|\/)\.\.?$|\/$/ +const ALIASED_GLOB_RE = /import\.meta\.glob(?:<[^>]*>)?\(\s*\[?\s*['"]\$/ function* walk(dir) { for (const entry of readdirSync(dir)) { @@ -50,9 +92,13 @@ function relativeSpecifier(fromFile, toFile) { let rewrittenFiles = 0 let rewrittenImports = 0 +const unresolvable = [] for (const file of walk(packageDir)) { const content = readFileSync(file, 'utf-8') + if (ALIASED_GLOB_RE.test(content)) { + unresolvable.push(`${relative(packageDir, file)}: import.meta.glob on an alias`) + } let changed = false const updated = content.replace(IMPORT_SPECIFIER_RE, (match, prefix, quote, specifier) => { if (OAUTH_CONNECT_RE.test(specifier)) { @@ -60,10 +106,27 @@ for (const file of walk(packageDir)) { rewrittenImports++ return `${prefix}${quote}${relativeSpecifier(file, oauthConnectDest)}${quote}` } + if (CHAT_SDK_RE.test(specifier)) { + changed = true + rewrittenImports++ + return `${prefix}${quote}${relativeSpecifier(file, join(chatSdkDest, 'index.js'))}${quote}` + } + const target = resolve(dirname(file), specifier) + if (target !== packageDir && !target.startsWith(packageDir + sep)) { + unresolvable.push(`${relative(packageDir, file)}: ${specifier}`) + return match + } + if (DIRECTORY_RE.test(specifier)) { + if (!existsSync(join(target, 'index.js')) && existsSync(target + '.js')) { + changed = true + rewrittenImports++ + return `${prefix}${quote}${relativeSpecifier(file, target + '.js')}${quote}` + } + return match + } if (!specifier.endsWith('.svelte')) { return match } - const target = resolve(dirname(file), specifier) // A real `.svelte` component file: leave the import alone. if (existsSync(target)) { return match @@ -82,6 +145,14 @@ for (const file of walk(packageDir)) { } } +if (unresolvable.length > 0) { + console.error( + `fix-svelte-module-imports: ${unresolvable.length} import(s) a consumer cannot resolve:\n` + + unresolvable.join('\n') + ) + process.exit(1) +} + console.log( `fix-svelte-module-imports: rewrote ${rewrittenImports} import(s) in ${rewrittenFiles} file(s)` ) diff --git a/frontend/src/lib/components/mcp/providerIcon.ts b/frontend/src/lib/components/mcp/providerIcon.ts index ed8d059a57..a666b2b1c5 100644 --- a/frontend/src/lib/components/mcp/providerIcon.ts +++ b/frontend/src/lib/components/mcp/providerIcon.ts @@ -12,8 +12,11 @@ import { findMcpEntry, findMcpEntryByUrl } from './registry' * the chat does not otherwise reach that barrel. `import.meta.glob` gives the * filenames at build time and the module only when a match is actually used, so * a workspace with two connections downloads two icons. + * + * The pattern is relative because svelte-package rewrites `$lib` only in import + * specifiers: an aliased glob ships as-is and no consumer's bundler expands it. */ -const iconModules = import.meta.glob('$lib/components/icons/*.svelte') as Record< +const iconModules = import.meta.glob('../icons/*.svelte') as Record< string, () => Promise<{ default: Component }> > From a8e2f1f1ca28e3352aff8a450188e3ff75a9637c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 12 Oct 2026 11:45:03 +0200 Subject: [PATCH 153/174] fix: keep ai agent schema property order stable across jobs (#11687) * fix: keep ai agent schema property order stable across jobs Co-Authored-By: Claude Opus 5.5 * fix: keep tool parameter order when filtering preset inputs Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-ai/src/types.rs | 45 +++++++++++++++++++------ backend/windmill-worker/src/ai/utils.rs | 35 +++++++++++++++++-- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index e591e85159..59733e352c 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -1,3 +1,4 @@ +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::collections::HashMap; @@ -533,7 +534,7 @@ pub struct OpenAPISchema { #[serde(skip_serializing_if = "Option::is_none")] pub items: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub properties: Option>>, + pub properties: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub required: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "oneOf")] @@ -560,9 +561,9 @@ pub struct OpenAPISchema { #[serde(skip_serializing_if = "Option::is_none", rename = "$ref")] pub ref_path: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "$defs")] - pub defs: Option>>, + pub defs: Option>>, #[serde(skip_serializing_if = "Option::is_none")] - pub definitions: Option>>, + pub definitions: Option>>, // Schema composition #[serde(skip_serializing_if = "Option::is_none", rename = "allOf")] @@ -829,7 +830,7 @@ impl OpenAPISchema { // Merge properties (other's properties are added if key doesn't exist) if let Some(ref other_props) = other.properties { - let props = self.properties.get_or_insert_with(HashMap::new); + let props = self.properties.get_or_insert_with(IndexMap::new); for (key, value) in other_props { props.entry(key.clone()).or_insert_with(|| value.clone()); } @@ -880,13 +881,13 @@ impl OpenAPISchema { // Merge $defs and definitions if let Some(ref other_defs) = other.defs { - let defs = self.defs.get_or_insert_with(HashMap::new); + let defs = self.defs.get_or_insert_with(IndexMap::new); for (key, value) in other_defs { defs.entry(key.clone()).or_insert_with(|| value.clone()); } } if let Some(ref other_definitions) = other.definitions { - let definitions = self.definitions.get_or_insert_with(HashMap::new); + let definitions = self.definitions.get_or_insert_with(IndexMap::new); for (key, value) in other_definitions { definitions .entry(key.clone()) @@ -981,7 +982,29 @@ pub struct S3ObjectWithType { #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; + + /// The schema is part of the prompt prefix the provider caches on, so its keys must + /// serialize in the same order on every request. + #[test] + fn strict_schema_keeps_the_defined_property_order() { + let keys = [ + "zeta", "alpha", "mid", "beta", "omega", "gamma", "delta", "kappa", + ]; + let props = keys + .iter() + .map(|k| format!(r#""{k}":{{"type":"string"}}"#)) + .collect::>() + .join(","); + let mut schema: OpenAPISchema = + serde_json::from_str(&format!(r#"{{"type":"object","properties":{{{props}}}}}"#)) + .unwrap(); + schema.make_strict(); + + let value = serde_json::to_value(&schema).unwrap(); + let serialized: Vec<&String> = value["properties"].as_object().unwrap().keys().collect(); + assert_eq!(serialized, keys); + assert_eq!(value["required"], serde_json::json!(keys)); + } /// Whichever provider served the request, `input_tokens` has to end up meaning the /// whole prompt. Leaving the Anthropic shape as reported under-states it by the @@ -1248,7 +1271,7 @@ mod tests { #[test] fn test_make_strict_defs() { let def_schema = object_schema(vec![("prop", string_schema())]); - let mut defs = HashMap::new(); + let mut defs = IndexMap::new(); defs.insert("MyType".to_string(), Box::new(def_schema)); let mut schema = OpenAPISchema { defs: Some(defs), ..Default::default() }; @@ -1274,7 +1297,7 @@ mod tests { #[test] fn test_make_strict_definitions() { let def_schema = object_schema(vec![("prop", string_schema())]); - let mut definitions = HashMap::new(); + let mut definitions = IndexMap::new(); definitions.insert("MyType".to_string(), Box::new(def_schema)); let mut schema = OpenAPISchema { definitions: Some(definitions), ..Default::default() }; @@ -1528,7 +1551,7 @@ mod tests { #[test] fn test_flatten_all_of_preserves_defs() { let def_schema = object_schema(vec![("field", string_schema())]); - let mut defs = HashMap::new(); + let mut defs = IndexMap::new(); defs.insert("MyType".to_string(), Box::new(def_schema)); let schema_with_defs = OpenAPISchema { defs: Some(defs), ..Default::default() }; @@ -1642,7 +1665,7 @@ mod tests { schema_url: Some("http://json-schema.org/draft-07/schema#".to_string()), ..Default::default() }; - let mut defs = HashMap::new(); + let mut defs = IndexMap::new(); defs.insert("MyType".to_string(), Box::new(def_schema)); let mut schema = OpenAPISchema { diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index 50bbcad58f..f0a0b5c20f 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -103,9 +103,9 @@ pub fn filter_schema_by_input_transforms( .get_mut("properties") .and_then(|p| p.as_object_mut()) { - for key in &keys_to_remove { - properties.remove(key); - } + // `remove` swaps the last entry into the freed slot, so removing in set order + // would leave the survivors in a different order on every run. + properties.retain(|key, _| !keys_to_remove.contains(key)); } // Also remove from required array @@ -748,3 +748,32 @@ pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool { false }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A tool's schema sits in the prompt prefix the provider caches on, so the + /// parameters left for the model must keep their order on every run. + #[test] + fn filtering_preset_inputs_keeps_the_remaining_property_order() { + let keys = ["a", "b", "c", "d", "e", "f", "g", "h"]; + let props = keys + .iter() + .map(|k| format!(r#""{k}":{{"type":"string"}}"#)) + .collect::>() + .join(","); + let schema = + RawValue::from_string(format!(r#"{{"type":"object","properties":{{{props}}}}}"#)) + .unwrap(); + let preset = ["a", "c", "f"] + .iter() + .map(|k| (k.to_string(), InputTransform::Static { value: to_raw_value(&1) })) + .collect(); + + let filtered = filter_schema_by_input_transforms(schema, &preset).unwrap(); + let value: serde_json::Value = serde_json::from_str(filtered.get()).unwrap(); + let remaining: Vec<&String> = value["properties"].as_object().unwrap().keys().collect(); + assert_eq!(remaining, ["b", "d", "e", "g", "h"]); + } +} From 54eeb451e4c083e55d5f9b35692d65304c8abc49 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:31:01 +0200 Subject: [PATCH 154/174] refactor: remove vue as a full-code app framework (#11690) Co-authored-by: Claude Opus 5.5 --- backend/windmill-api/src/apps_raw_bundler.ts | 2 +- cli/src/commands/app/bundle.ts | 22 +- cli/src/commands/app/bundle_command.ts | 4 +- cli/src/commands/app/dev.ts | 6 +- cli/src/commands/app/lint.ts | 2 +- cli/src/commands/app/new.ts | 37 +- cli/src/commands/app/raw_apps.ts | 2 +- cli/src/guidance/skills.gen.ts | 16 +- .../components/copilot/chat/global/core.ts | 9 +- .../components/raw_apps/RawAppEditor.svelte | 5 +- .../src/lib/components/raw_apps/templates.ts | 408 +----------------- .../auto-generated/cli/cli-commands.md | 4 +- system_prompts/auto-generated/prompts.ts | 12 +- .../skills/cli-commands/SKILL.md | 4 +- .../auto-generated/skills/raw-app/SKILL.md | 12 +- system_prompts/base/raw-app-cli.md | 4 +- system_prompts/base/raw-app.md | 8 +- 17 files changed, 52 insertions(+), 505 deletions(-) diff --git a/backend/windmill-api/src/apps_raw_bundler.ts b/backend/windmill-api/src/apps_raw_bundler.ts index 6663a0f83e..965a9289b5 100644 --- a/backend/windmill-api/src/apps_raw_bundler.ts +++ b/backend/windmill-api/src/apps_raw_bundler.ts @@ -6,7 +6,7 @@ * The build itself is `wmill app bundle`, the same one `wmill app push` runs, so * an app deployed through the API is compiled exactly as the CLI and the editor * compile it — entry point, virtual `wmill` module, `/ui/` shared-UI resolution - * and the Svelte/Vue plugins included. Reimplementing any of that here would be + * and the Svelte plugin included. Reimplementing any of that here would be * a third bundler to keep in step with the other two. */ declare const __wmillAppPolicy: { diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index 286e878cfb..287d01ebed 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -58,10 +58,10 @@ function conditionsFor(svelte: boolean): string[] { /** * Detects which frontend frameworks are present in package.json */ -export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolean } { +export function detectFrameworks(appDir: string): { svelte: boolean } { const packageJsonPath = path.join(appDir, "package.json"); if (!fs.existsSync(packageJsonPath)) { - return { svelte: false, vue: false }; + return { svelte: false }; } try { @@ -73,10 +73,9 @@ export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolea return { svelte: "svelte" in allDeps, - vue: "vue" in allDeps, }; } catch { - return { svelte: false, vue: false }; + return { svelte: false }; } } @@ -256,17 +255,6 @@ export async function createFrameworkPlugins(appDir: string): Promise { plugins.push(createSveltePlugin(appDir)); } - if (frameworks.vue) { - log.info(colors.blue("🔧 Vue detected, adding vue plugin...")); - throw new Error("Vue plugin not supported yet"); - // try { - // const esbuildPluginVue = await import("esbuild-plugin-vue3"); - // plugins.push(esbuildPluginVue.default()); - // } catch (error: any) { - // log.warn(colors.yellow(`Failed to load vue plugin: ${error.message}`)); - // } - } - return plugins; } @@ -313,7 +301,7 @@ export async function createBundle( // Use the entryPoint's directory if provided, otherwise fall back to cwd. const appDir = options.entryPoint ? path.dirname(options.entryPoint) : process.cwd(); const frameworks = detectFrameworks(appDir); - const defaultEntry = (frameworks.svelte || frameworks.vue) ? "index.ts" : "index.tsx"; + const defaultEntry = frameworks.svelte ? "index.ts" : "index.tsx"; const entryPoint = options.entryPoint ?? defaultEntry; const outDir = options.outDir ?? "dist"; @@ -331,7 +319,7 @@ export async function createBundle( // Ensure node_modules exists in the app directory await ensureNodeModules(appDir); - // Load framework-specific plugins (svelte, vue) based on package.json + // Load framework-specific plugins (svelte) based on package.json const frameworkPlugins = await createFrameworkPlugins(appDir); // Ensure output directory exists diff --git a/cli/src/commands/app/bundle_command.ts b/cli/src/commands/app/bundle_command.ts index bef13f4268..c4119aafb0 100644 --- a/cli/src/commands/app/bundle_command.ts +++ b/cli/src/commands/app/bundle_command.ts @@ -12,10 +12,10 @@ interface BundleOptions { } /** Every entry point a raw app may have, most specific first, with the - * framework's preferred one promoted — Svelte and Vue mount from a `.ts`. */ + * framework's preferred one promoted — Svelte mounts from a `.ts`. */ function entryPointFor(appDir: string): string { const frameworks = detectFrameworks(appDir); - const candidates = frameworks.svelte || frameworks.vue + const candidates = frameworks.svelte ? ["index.ts", "index.tsx", "index.js"] : ["index.tsx", "index.ts", "index.js"]; const entry = candidates.find((c) => fs.existsSync(path.join(appDir, c))); diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 2dac8639e2..fdf26fa7da 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -481,7 +481,7 @@ async function dev(opts: DevOptions, appFolder?: string) { // Detect frameworks to determine default entry point const frameworks = detectFrameworks(process.cwd()); - const defaultEntry = frameworks.svelte || frameworks.vue + const defaultEntry = frameworks.svelte ? "index.ts" : "index.tsx"; const entryPoint = opts.entry ?? defaultEntry; @@ -577,7 +577,7 @@ async function dev(opts: DevOptions, appFolder?: string) { const buildOptions = getDevBuildOptions(entryPoint, frameworks.svelte); - // Load framework-specific plugins (svelte, vue) based on package.json + // Load framework-specific plugins (svelte) based on package.json const frameworkPlugins = await createFrameworkPlugins(appDir); const wmillPlugin = { @@ -1602,7 +1602,7 @@ const command = new Command() }) .option( "--entry ", - "Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)", + "Entry point file (default: index.ts for Svelte, index.tsx otherwise)", ) .option("--no-open", "Don't automatically open the browser") .option( diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index 9356b5dd3c..2276a77cad 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -114,7 +114,7 @@ async function validateBuild( // Try to create a bundle - this will validate that all dependencies are in place const frameworks = detectFrameworks(appDir); - const entryFile = frameworks.svelte || frameworks.vue ? "index.ts" : "index.tsx"; + const entryFile = frameworks.svelte ? "index.ts" : "index.tsx"; const entryPoint = path.join(appDir, entryFile); await createBundle({ entryPoint, diff --git a/cli/src/commands/app/new.ts b/cli/src/commands/app/new.ts index b011965ae8..40cc2a4416 100644 --- a/cli/src/commands/app/new.ts +++ b/cli/src/commands/app/new.ts @@ -85,21 +85,6 @@ const app = mount(App, { target: document.getElementById("root")! }); export default app; `; -const appVue = ` - -`; - -const indexVue = `import { createApp } from 'vue' -import App from './App.vue' -import "./index.css"; - -createApp(App).mount('#root')`; - const indexCss = `body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; @@ -171,21 +156,6 @@ const templates: Record = { "svelte": "^5.55.5", "windmill-client": "^1" } -}`, - }, - }, - vue: { - name: "Vue 3", - files: { - "/index.ts": indexVue, - "/App.vue": appVue, - "/index.css": indexCss, - "/package.json": `{ - "dependencies": { - "core-js": "3.26.1", - "vue": "3.5.13", - "windmill-client": "^1" - } }`, }, }, @@ -251,7 +221,7 @@ interface DataConfig { schema?: string; } -type FrameworkKey = "react19" | "react18" | "svelte5" | "vue"; +type FrameworkKey = "react19" | "react18" | "svelte5"; interface NewAppOptions extends GlobalOptions { /** App summary (short description). Skips the prompt when provided. */ @@ -384,7 +354,7 @@ async function newApp(opts: NewAppOptions) { } // Ask for framework (skipped if --framework is provided) - const VALID_FRAMEWORKS: FrameworkKey[] = ["react19", "react18", "svelte5", "vue"]; + const VALID_FRAMEWORKS: FrameworkKey[] = ["react19", "react18", "svelte5"]; let framework: string; if (opts.framework !== undefined) { if (!VALID_FRAMEWORKS.includes(opts.framework)) { @@ -403,7 +373,6 @@ async function newApp(opts: NewAppOptions) { { name: "React 19 (Recommended)", value: "react19" }, { name: "React 18", value: "react18" }, { name: "Svelte 5", value: "svelte5" }, - { name: "Vue 3", value: "vue" }, ], }); } @@ -900,7 +869,7 @@ const command = new Command() ) .option( "--framework ", - "Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode." + "Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode." ) .option( "--datatable ", diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 36c8260e25..16179d4662 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -461,7 +461,7 @@ export async function pushRawApp( log.info(colors.yellow.bold(`Creating raw app ${remotePath} bundle...`)); // Detect frameworks to determine entry point const frameworks = detectFrameworks(localPath); - const entryFile = frameworks.svelte || frameworks.vue + const entryFile = frameworks.svelte ? "index.ts" : "index.tsx"; const entryPoint = localPath + entryFile; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index eb5d46ef57..4e8cc464d5 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6351,7 +6351,7 @@ You need three values to run the command: 1. **summary** — a short description of the app 2. **path** — the windmill path, e.g. \`f/folder/my_app\` or \`u/username/my_app\` -3. **framework** — one of \`react19\` (recommended), \`react18\`, \`svelte5\`, \`vue\` +3. **framework** — one of \`react19\` (recommended), \`react18\`, \`svelte5\` **summary and path**: if the user's request did not supply them, ask. Do not guess values and do not invent paths. @@ -6423,7 +6423,7 @@ my_app{{RAW_APP_SUFFIX}}/ ├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh) ├── raw_app.yaml # App configuration (summary, path, data settings) ├── index.tsx # Frontend entry point -├── App.tsx # Main React/Svelte/Vue component +├── App.tsx # Main React/Svelte component ├── index.css # Styles ├── package.json # Frontend dependencies ├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT) @@ -6581,7 +6581,7 @@ For the rest, tell the user which command fits their intent and let them run it # Windmill Raw Apps -Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. +Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables. ## App shape @@ -6597,7 +6597,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t ### Framework -Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for. +Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead. ### Data setup @@ -6627,7 +6627,7 @@ Then build what was chosen: the data table and schema become the app's \`data.da ### Entrypoint -The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`
\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` / \`App.vue\` and keep the entrypoint as the mount shim. +The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`
\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` and keep the entrypoint as the mount shim. React (\`index.tsx\`): @@ -6639,7 +6639,7 @@ import App from './App' createRoot(document.getElementById('root')!).render() \`\`\` -Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`. Vue (\`index.ts\`): \`createApp(App).mount('#root')\`. +Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`. **Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`. @@ -8102,7 +8102,7 @@ app related commands - \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement - \`--port \` - Port to run the dev server on (will find next available port if occupied) - \`--host \` - Host to bind the dev server to - - \`--entry \` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise) + - \`--entry \` - Entry point file (default: index.ts for Svelte, index.tsx otherwise) - \`--no-open\` - Don't automatically open the browser - \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development - \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability @@ -8113,7 +8113,7 @@ app related commands - \`app new\` - create a new raw app from a template - \`--summary \` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode. - \`--path \` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode. - - \`--framework \` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode. + - \`--framework \` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode. - \`--datatable \` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured. - \`--schema \` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it. - \`--overwrite\` - Overwrite the target directory if it already exists, without prompting. diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 2a795f1f81..5295712626 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -1306,12 +1306,7 @@ const readDomSchema = z.object({ const takeScreenshotSchema = z.object({}) -const FRAMEWORK_KEYS = [ - 'react19', - 'react18', - 'svelte5', - 'vue' -] as const satisfies readonly FrameworkKey[] +const FRAMEWORK_KEYS = ['react19', 'react18', 'svelte5'] as const satisfies readonly FrameworkKey[] const initAppSchema = z.object({ path: z @@ -2493,7 +2488,7 @@ function getAppInstructions(language?: ScriptLang): string { - Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy. - App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u//\` for bare names; only use \`f//\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate. -- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing. +- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing. - \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables. - Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`. - Backend inline runnables are addressed as \`backend//main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`). diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index f708b97efe..46c23b79b3 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -496,7 +496,7 @@ addEventListener('message', function (e) { // entrypoint defines a component without ever mounting it. Cleared on next build. let emptyRender = $state(false) // The repair hint above has to match the app's framework: only React apps have - // an `index.tsx` and `createRoot`; Svelte and Vue mount from `index.ts`. Keyed + // an `index.tsx` and `createRoot`; Svelte mounts from `index.ts`. Keyed // off file extensions rather than exact template filenames, which users rename. let mountHint = $derived.by(() => { const paths = Object.keys(files ?? {}).map((p) => p.replace(/^\//, '')) @@ -507,9 +507,6 @@ addEventListener('message', function (e) { call: "mount(App, { target: document.getElementById('root')! })" } } - if (paths.some((p) => p.endsWith('.vue'))) { - return { entrypoint: entrypoint ?? 'index.ts', call: "createApp(App).mount('#root')" } - } return { entrypoint: entrypoint ?? 'index.tsx', call: "createRoot(document.getElementById('root')!).render()" diff --git a/frontend/src/lib/components/raw_apps/templates.ts b/frontend/src/lib/components/raw_apps/templates.ts index 50eae99e97..00d7594386 100644 --- a/frontend/src/lib/components/raw_apps/templates.ts +++ b/frontend/src/lib/components/raw_apps/templates.ts @@ -1,5 +1,5 @@ // The starter app is a tour of what a raw app can do: each card calls one of the -// STARTER_RUNNABLES below through a different `./wmill` export. The three framework +// STARTER_RUNNABLES below through a different `./wmill` export. The framework // variants share `indexCss` and must stay in step with each other and with the runnables. const reactIndex = ` @@ -13,7 +13,7 @@ root.render(); ` const STATS_SAMPLE_TEXT = - 'Windmill turns scripts into apps. Write the frontend in React, Svelte or Vue, and the backend in TypeScript, Python or any other supported language. Scripts run on workers, and the app calls them like functions.' + 'Windmill turns scripts into apps. Write the frontend in React or Svelte, and the backend in TypeScript, Python or any other supported language. Scripts run on workers, and the app calls them like functions.' const appTsx = `import React, { useState } from 'react' import { backend, backendAsync, getJob, streamJob } from './wmill' @@ -516,230 +516,6 @@ const app = mount(App, { target: document.getElementById("root")! }); export default app; ` -const appVue = ` - -` - -const indexVue = `import { createApp } from 'vue' -import App from './App.vue' -import "./index.css"; - -createApp(App).mount('#root')` - const indexCss = `/* The app renders inside an iframe whose host page has its own theme the iframe cannot read, so paint an opaque surface here: without one the host's background shows through and dark text lands on it unreadable. */ @@ -1158,23 +934,10 @@ export const svelte5Template = { }` } -export const vueTemplate = { - '/index.ts': indexVue, - '/App.vue': appVue, - '/index.css': indexCss, - '/package.json': `{ - "dependencies": { - "core-js": "3.26.1", - "vue": "3.5.13" - } -}` -} - export const FRAMEWORK_TEMPLATES = { react19: react19Template, react18: react18Template, - svelte5: svelte5Template, - vue: vueTemplate + svelte5: svelte5Template } export type FrameworkKey = keyof typeof FRAMEWORK_TEMPLATES @@ -1551,171 +1314,6 @@ export async function main() { } } -export const appVueRouter = ` - - - - -` - export const appSvelteRouter = ` + +{#if loadError} + {loadError} +{:else if status} +
+
+ Tokens are signed by: + {modeLabels[status.mode]} +
+ {#if status.signer} +
+ Last key listing: + {status.signer.last_refresh_success + ? displayDate(status.signer.last_refresh_success) + : 'never'} + {#if status.signer.breaker !== 'closed'} + Signer unreachable + {:else if !status.signer.last_refresh_error && status.signer.last_refresh_success} + Signer reachable + {/if} +
+ {#if status.signer.last_refresh_error} + + {status.signer.last_refresh_error} + + {/if} + {#if status.signer.keys_from_store} + + The keys below are those stored by the last accepted listing. Tokens cannot be signed + until the signer answers. + + {/if} + {#if status.signer.tls_files_error} + + {status.signer.tls_files_error}. The previous certificate and key are kept. + + {/if} + {/if} + + +
+ Key ID (kid) + Algorithm + State + Source + + + + {#each status.keys as key, i (`${key.source}:${key.kid ?? i}`)} + + + + {key.kid} + {#if key.kid}{/if} + + + {key.alg} + + {key.state} + + {key.source} + + {/each} + + + {#if status.keys.length === 0} +
The JWKS serves no key.
+ {/if} + +{/if} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index aa7a1a14df..d863eb7848 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -11,12 +11,14 @@ tabToAuthSubTab, categoryToTabMap, buildSearchableSettingItems, + listedNavigationGroups, + usesExternalOidcSigner, type SearchableSettingItem } from '$lib/components/instanceSettings' import SettingsSearchInput from '$lib/components/instanceSettings/SettingsSearchInput.svelte' import Breadcrumb from '$lib/components/common/breadcrumb/Breadcrumb.svelte' import { ChevronRight, ArrowLeft } from 'lucide-svelte' - import { superadmin } from '$lib/stores' + import { enterpriseLicense, superadmin } from '$lib/stores' import { onDestroy, tick } from 'svelte' import { SettingService, UserService } from '$lib/gen' import { sendUserToast } from '$lib/toast' @@ -178,7 +180,14 @@ } // --- Settings search (full mode) --- - const searchableItems = buildSearchableSettingItems(setupNavigationGroups) + let externalOidcSigner = $state(false) + $effect(() => { + if ($enterpriseLicense) usesExternalOidcSigner().then((v) => (externalOidcSigner = v)) + }) + const navigationGroups = $derived( + listedNavigationGroups(setupNavigationGroups, externalOidcSigner) + ) + const searchableItems = $derived(buildSearchableSettingItems(navigationGroups)) let scrollTimeout: ReturnType | undefined let highlightTimeout: ReturnType | undefined @@ -487,7 +496,7 @@
From 72d1092de97d7449ff03c3a44835c9d23cfe5e02 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 18:47:07 +0200 Subject: [PATCH 158/174] fix: run a workflow-as-code task called from inside a task in place (#11699) * fix: run a workflow-as-code task called from inside a task in place Co-Authored-By: Claude Opus 5.5 * fix: hand an async task body an awaitable for a sync nested task Co-Authored-By: Claude Opus 5.5 * fix: mark the task-body scope per nested task body Co-Authored-By: Claude Opus 5.5 * docs: note the task-body behaviour of step and sleep Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cli/src/guidance/skills.gen.ts | 37 ++++++-- python-client/wmill/tests/test_workflow.py | 80 +++++++++++++++- python-client/wmill/wmill/client.py | 88 ++++++++++++++++-- system_prompts/auto-generated/prompts.ts | 31 +++++-- system_prompts/auto-generated/script.md | 12 ++- system_prompts/auto-generated/sdks/python.md | 9 +- .../auto-generated/sdks/typescript.md | 3 + .../auto-generated/sdks/wac-python.md | 9 +- .../auto-generated/sdks/wac-typescript.md | 8 +- .../skills/write-script-bun/SKILL.md | 3 + .../skills/write-script-bunnative/SKILL.md | 3 + .../skills/write-script-deno/SKILL.md | 3 + .../skills/write-script-python3/SKILL.md | 9 +- .../skills/write-workflow-as-code/SKILL.md | 19 +++- system_prompts/base/workflow-as-code.md | 2 + typescript-client/client.ts | 75 ++++++++++++--- .../tests/workflow_nested_task.test.ts | 91 +++++++++++++++++++ 17 files changed, 438 insertions(+), 44 deletions(-) create mode 100644 typescript-client/tests/workflow_nested_task.test.ts diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 4e8cc464d5..fff7df1153 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -1106,6 +1106,9 @@ async sleep(seconds: number): Promise * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> @@ -1949,6 +1952,9 @@ async sleep(seconds: number): Promise * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> @@ -2898,6 +2904,9 @@ async sleep(seconds: number): Promise * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> @@ -5073,7 +5082,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a \`\`datetime\`\` comes back as a @@ -5159,12 +5170,15 @@ def workflow(func) # \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a \`\`datetime\`\` comes back as a string, a tuple as a list. +# +# Called from inside a task body, \`\`fn\`\` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`. -# Outside a workflow, falls back to \`\`asyncio.sleep\`\`. +# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. @@ -7436,6 +7450,8 @@ async def enrich(customer_id: str): In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`. +Only the workflow body dispatches steps. A task runs as a single job, so a task or \`step()\` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. \`sleep()\` there waits in the job, and \`taskScript()\` / \`taskFlow()\` / \`waitForApproval()\` (\`task_script()\` / \`task_flow()\` / \`wait_for_approval()\`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them. + For existing scripts: \`\`\`typescript @@ -7601,8 +7617,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a \`workflow()\`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a \`Date\` comes back as a string, a @@ -7646,6 +7663,9 @@ export function workflow(fn: (...args: any[]) => Promise) * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step(name: string, fn: () => T | Promise,): Promise>> @@ -7744,7 +7764,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a \`\`datetime\`\` comes back as a @@ -7830,12 +7852,15 @@ def workflow(func) # \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a \`\`datetime\`\` comes back as a string, a tuple as a list. +# +# Called from inside a task body, \`\`fn\`\` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`. -# Outside a workflow, falls back to \`\`asyncio.sleep\`\`. +# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/python-client/wmill/tests/test_workflow.py b/python-client/wmill/tests/test_workflow.py index ceed39bfeb..d32ae1458b 100644 --- a/python-client/wmill/tests/test_workflow.py +++ b/python-client/wmill/tests/test_workflow.py @@ -7,7 +7,7 @@ import pytest from datetime import datetime, timezone -from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async +from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, task_script, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async class _FakeItems(dict): @@ -752,6 +752,84 @@ class TestChildModeParallel: assert result["result"] == 8 +class TestSdkCallsInsideTaskBody: + """A task body runs as one child job, so the SDK calls it makes cannot be + dispatched as steps of their own.""" + + def _run_child(self, wf, key): + async def bounded(): + return await asyncio.wait_for( + _run_workflow_async(wf, {"completed_steps": {}, "_executing_key": key}, {}), + timeout=2, + ) + + return asyncio.run(bounded()) + + def test_nested_task_and_step_run_in_the_tasks_own_job(self): + @task + async def inner(x: str): + return f"inner:{x}" + + @task + def sync_leaf(x: str): + return f"sync:{x}" + + # A sync body calls its tasks without awaiting, whatever called it. + @task + def sync_inner(x: str): + return sync_leaf(x).upper().lower() + + @task + async def outer(x: str): + via_task = await inner(x) + via_sync = await sync_inner(x) + via_step = await step("s", lambda: "stepped") + return f"{via_task}+{via_sync}+{via_step}" + + @workflow + async def wf(): + return await outer("a") + + result = self._run_child(wf, "outer") + assert result == {"type": "complete", "result": "inner:a+sync:a+stepped"} + + def test_sibling_task_of_the_workflow_body_is_not_run(self): + # The workflow body keeps replaying beside the task body, and its own + # calls must stay parked: running them would execute a sibling task in + # this job. + ran = [] + + @task + async def inner(x: str): + ran.append(x) + return x + + @task + async def outer(x: str): + await asyncio.sleep(0) + return await inner("nested") + + @workflow + async def wf(): + return await asyncio.gather(outer("a"), inner("sibling")) + + result = self._run_child(wf, "outer") + assert result == {"type": "complete", "result": "nested"} + assert ran == ["nested"] + + def test_step_that_would_need_a_dispatch_fails_instead_of_hanging(self): + @task + async def dispatching(): + return await task_script("f/other/script")() + + @workflow + async def wf(): + return await dispatching() + + with pytest.raises(RuntimeError, match="cannot be called from inside a task"): + self._run_child(wf, "dispatching") + + class TestKeyDeterminism: def test_keys_consistent_across_replays(self): r1 = _run_workflow(det_wf, {}, {"n": 3}) diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index 97da60aadc..824db9741f 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -2766,6 +2766,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]: import asyncio as _asyncio import contextvars as _contextvars +import inspect as _inspect import sys as _sys import traceback as _traceback @@ -2993,6 +2994,64 @@ _workflow_ctx: _contextvars.ContextVar["WorkflowCtx"] = _contextvars.ContextVar( "_workflow_ctx" ) +# Set while a task body runs in its child job: ``"async"`` or ``"sync"`` after +# the kind of function the body is. A flag on the ctx cannot stand in: under +# ``asyncio.gather`` the workflow body keeps replaying beside the task body, and +# its calls must still park rather than run a sibling task's body in this job. +_in_task_body: _contextvars.ContextVar[Optional[str]] = _contextvars.ContextVar( + "_in_task_body", default=None +) + + +def _dispatch_ctx() -> "WorkflowCtx | None": + """The ctx SDK calls dispatch through, or None where they run in place: + outside a workflow, and inside a task body, which is one job and has no + rounds of its own to dispatch or checkpoint a step in.""" + if _in_task_body.get(): + return None + return _workflow_ctx.get(None) + + +async def _resolved(value): + return value + + +def _call_in_task_body(func, caller: str, args, kwargs): + """Run a task called from inside a task body in place, round-tripping its + result as a dispatched one would be. + + The workflow body awaits every task, sync ones included, so an async caller + gets an awaitable back either way; a sync caller gets the value. The scope + is re-marked for the nested body, which may be of the other kind.""" + + async def run_async(coro): + token = _in_task_body.set("async") + try: + return _json_round_trip(await coro) + finally: + _in_task_body.reset(token) + + if _inspect.iscoroutinefunction(func): + return run_async(func(*args, **kwargs)) + token = _in_task_body.set("sync") + try: + result = func(*args, **kwargs) + finally: + _in_task_body.reset(token) + if _asyncio.iscoroutine(result): + return run_async(result) + result = _json_round_trip(result) + return _resolved(result) if caller == "async" else result + + +def _assert_not_inside_task_body(what: str) -> None: + if _in_task_body.get(): + raise RuntimeError( + f"{what} cannot be called from inside a task: a task runs as a single " + "job and cannot suspend or dispatch steps. Call it from the @workflow " + "body instead." + ) + class WorkflowCtx: """Internal context for workflow replay/suspension. @@ -3117,15 +3176,20 @@ class WorkflowCtx: raise _StepSuspend({"mode": "sleep", "key": key, "seconds": seconds, "steps": []}) async def _resolved(self, value): - return value + return await _resolved(value) async def _execute_directly(self, func, **kwargs): + token = _in_task_body.set( + "async" if _inspect.iscoroutinefunction(func) else "sync" + ) try: result = func(**kwargs) if _asyncio.iscoroutine(result): result = await result except Exception as exc: raise _StepFailure(exc) from exc + finally: + _in_task_body.reset(token) raise _StepSuspend({"mode": "step_complete", "steps": [], "result": result}) async def _never_resolve(self): @@ -3367,7 +3431,9 @@ def task( - **v2 (inside @workflow)**: dispatches as a checkpoint step. - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. - - **Standalone**: executes the function body directly. + - **Standalone, or called from inside another v2 task**: executes the + function body directly. A task runs as a single job, so the tasks it + calls run in that same job, without their own options. A task runs as its own job, so its result is always encoded as JSON and decoded back before the caller sees it: a ``datetime`` comes back as a @@ -3447,12 +3513,16 @@ def task( @functools.wraps(func) def wrapper(*args, **kwargs): # WAC v2: inside a @workflow context - ctx = _workflow_ctx.get(None) + ctx = _dispatch_ctx() if ctx is not None: script = task_path if task_path else task_name merged = _merge_args(args, kwargs) return ctx._next_step(task_name, script, func, _task_options=_task_opts, **merged) + caller = _in_task_body.get() + if caller is not None: + return _call_in_task_body(func, caller, args, kwargs) + # WAC v1: running inside a Windmill job but not in a @workflow if ( os.environ.get("WM_JOB_ID") is not None @@ -3531,6 +3601,7 @@ def task_script( _opts = {k: v for k, v in {"timeout": timeout, "tag": tag, "cache_ttl": cache_ttl, "priority": priority, "concurrent_limit": concurrency_limit, "concurrency_key": concurrency_key, "concurrency_time_window_s": concurrency_time_window_s, "retry": _checked_retry(retry)}.items() if v is not None} or None def wrapper(**kwargs): + _assert_not_inside_task_body(f'task_script("{path}")') ctx = _workflow_ctx.get(None) if ctx is not None: return ctx._next_step(name, path, dispatch_type="script", _task_options=_opts, **kwargs) @@ -3570,6 +3641,7 @@ def task_flow( _opts = {k: v for k, v in {"timeout": timeout, "tag": tag, "cache_ttl": cache_ttl, "priority": priority, "concurrent_limit": concurrency_limit, "concurrency_key": concurrency_key, "concurrency_time_window_s": concurrency_time_window_s, "retry": _checked_retry(retry)}.items() if v is not None} or None def wrapper(**kwargs): + _assert_not_inside_task_body(f'task_flow("{path}")') ctx = _workflow_ctx.get(None) if ctx is not None: return ctx._next_step(name, path, dispatch_type="flow", _task_options=_opts, **kwargs) @@ -3604,8 +3676,11 @@ async def step(name: str, fn): ``fn``'s result is encoded as JSON and decoded back before it is returned, so the round that runs the body sees the same types every replay sees: a ``datetime`` comes back as a string, a tuple as a list. + + Called from inside a task body, ``fn`` runs in place and nothing is + checkpointed: a task is a single job and is never replayed. """ - ctx: WorkflowCtx | None = _workflow_ctx.get(None) + ctx = _dispatch_ctx() if ctx is not None: return await ctx._run_inline_step(name, fn) result = fn() @@ -3620,9 +3695,9 @@ async def sleep(seconds: int): """Server-side sleep — suspend the workflow for the given duration without holding a worker. Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. - Outside a workflow, falls back to ``asyncio.sleep``. + Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. """ - ctx: WorkflowCtx | None = _workflow_ctx.get(None) + ctx = _dispatch_ctx() if ctx is not None: return await ctx._sleep(seconds) await _asyncio.sleep(seconds) @@ -3660,6 +3735,7 @@ async def wait_for_approval( await step("notify", lambda: send_email(urls["resume"], urls["cancel"])) result = await wait_for_approval(key="manager", timeout=3600) """ + _assert_not_inside_task_body("wait_for_approval()") ctx: WorkflowCtx | None = _workflow_ctx.get(None) if ctx is not None: return await ctx._wait_for_approval( diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index cfa3415e35..393b724f6b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1427,6 +1427,8 @@ async def enrich(customer_id: str): In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`. +Only the workflow body dispatches steps. A task runs as a single job, so a task or \`step()\` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. \`sleep()\` there waits in the job, and \`taskScript()\` / \`taskFlow()\` / \`waitForApproval()\` (\`task_script()\` / \`task_flow()\` / \`wait_for_approval()\`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them. + For existing scripts: \`\`\`typescript @@ -2029,6 +2031,9 @@ async sleep(seconds: number): Promise * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> @@ -2871,7 +2876,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a \`\`datetime\`\` comes back as a @@ -2957,12 +2964,15 @@ def workflow(func) # \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a \`\`datetime\`\` comes back as a string, a tuple as a list. +# +# Called from inside a task body, \`\`fn\`\` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`. -# Outside a workflow, falls back to \`\`asyncio.sleep\`\`. +# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. @@ -3084,8 +3094,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a \`workflow()\`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a \`Date\` comes back as a string, a @@ -3129,6 +3140,9 @@ export function workflow(fn: (...args: any[]) => Promise) * \`fn\`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a \`Date\` * comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape. + * + * Called from inside a task body, \`fn\` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step(name: string, fn: () => T | Promise,): Promise>> @@ -3227,7 +3241,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a \`\`datetime\`\` comes back as a @@ -3313,12 +3329,15 @@ def workflow(func) # \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a \`\`datetime\`\` comes back as a string, a tuple as a list. +# +# Called from inside a task body, \`\`fn\`\` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`. -# Outside a workflow, falls back to \`\`asyncio.sleep\`\`. +# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 14ca869180..a4064c3d9e 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1906,6 +1906,9 @@ async sleep(seconds: number): Promise * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> @@ -2748,7 +2751,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -2834,12 +2839,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 13a93ed063..1964c88685 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -669,7 +669,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -755,12 +757,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index 55df35ccf7..da9e79ec94 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -463,6 +463,9 @@ async sleep(seconds: number): Promise * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> diff --git a/system_prompts/auto-generated/sdks/wac-python.md b/system_prompts/auto-generated/sdks/wac-python.md index a8e98a9d4f..6dd307dd24 100644 --- a/system_prompts/auto-generated/sdks/wac-python.md +++ b/system_prompts/auto-generated/sdks/wac-python.md @@ -36,7 +36,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -122,12 +124,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/sdks/wac-typescript.md b/system_prompts/auto-generated/sdks/wac-typescript.md index 608d75d18c..dd04a64de0 100644 --- a/system_prompts/auto-generated/sdks/wac-typescript.md +++ b/system_prompts/auto-generated/sdks/wac-typescript.md @@ -67,8 +67,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a `workflow()`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a `Date` comes back as a string, a @@ -112,6 +113,9 @@ export function workflow(fn: (...args: any[]) => Promise) * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step(name: string, fn: () => T | Promise,): Promise>> diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 6ad44db068..055b1613b2 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -659,6 +659,9 @@ async sleep(seconds: number): Promise * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index a5f599a946..beaf7817bb 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -671,6 +671,9 @@ async sleep(seconds: number): Promise * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index ce0c610da7..afefb7e987 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -661,6 +661,9 @@ async sleep(seconds: number): Promise * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ async step(name: string, fn: () => T | Promise,): Promise>> diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index 4946ed0f6e..ee50b6d62d 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -877,7 +877,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -963,12 +965,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/auto-generated/skills/write-workflow-as-code/SKILL.md b/system_prompts/auto-generated/skills/write-workflow-as-code/SKILL.md index 13d305c74e..09789b388a 100644 --- a/system_prompts/auto-generated/skills/write-workflow-as-code/SKILL.md +++ b/system_prompts/auto-generated/skills/write-workflow-as-code/SKILL.md @@ -143,6 +143,8 @@ async def enrich(customer_id: str): In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`. +Only the workflow body dispatches steps. A task runs as a single job, so a task or `step()` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. `sleep()` there waits in the job, and `taskScript()` / `taskFlow()` / `waitForApproval()` (`task_script()` / `task_flow()` / `wait_for_approval()`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them. + For existing scripts: ```typescript @@ -308,8 +310,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a `workflow()`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a `Date` comes back as a string, a @@ -353,6 +356,9 @@ export function workflow(fn: (...args: any[]) => Promise) * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step(name: string, fn: () => T | Promise,): Promise>> @@ -451,7 +457,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict # # - **v2 (inside @workflow)**: dispatches as a checkpoint step. # - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API. -# - **Standalone**: executes the function body directly. +# - **Standalone, or called from inside another v2 task**: executes the +# function body directly. A task runs as a single job, so the tasks it +# calls run in that same job, without their own options. # # A task runs as its own job, so its result is always encoded as JSON and # decoded back before the caller sees it: a ``datetime`` comes back as a @@ -537,12 +545,15 @@ def workflow(func) # ``fn``'s result is encoded as JSON and decoded back before it is returned, # so the round that runs the body sees the same types every replay sees: # a ``datetime`` comes back as a string, a tuple as a list. +# +# Called from inside a task body, ``fn`` runs in place and nothing is +# checkpointed: a task is a single job and is never replayed. async def step(name: str, fn) # Server-side sleep — suspend the workflow for the given duration without holding a worker. # # Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``. -# Outside a workflow, falls back to ``asyncio.sleep``. +# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``. async def sleep(seconds: int) # Suspend the workflow and wait for an external approval. diff --git a/system_prompts/base/workflow-as-code.md b/system_prompts/base/workflow-as-code.md index 0e529f6803..efc29df360 100644 --- a/system_prompts/base/workflow-as-code.md +++ b/system_prompts/base/workflow-as-code.md @@ -95,6 +95,8 @@ async def enrich(customer_id: str): In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`. +Only the workflow body dispatches steps. A task runs as a single job, so a task or `step()` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. `sleep()` there waits in the job, and `taskScript()` / `taskFlow()` / `waitForApproval()` (`task_script()` / `task_flow()` / `wait_for_approval()`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them. + For existing scripts: ```typescript diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 3f7b03117a..da957239cf 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1832,6 +1832,52 @@ export function setWorkflowCtx(ctx: WorkflowCtx | null) { Reflect.set(globalThis, "__wmill_wf_ctx", ctx); } +type TaskBodyScope = { run(store: true, fn: () => R): R; getStore(): true | undefined }; + +/** Marks the async context of a task body running in its child job. A flag on + * the ctx cannot stand in: the workflow body keeps replaying beside the task + * body (`Promise.all([a(), b()])`), and its calls must still park rather than + * run a sibling task's body in this job. Kept on `globalThis`, like the ctx, + * so a bundle holding two copies of this module shares one scope. Resolved + * through `process.getBuiltinModule` because a `node:` import would break + * browser bundles of this package. */ +function taskBodyScope(): TaskBodyScope | null { + const existing = Reflect.get(globalThis, "__wmill_wf_task_body"); + if (existing !== undefined) return existing; + let scope: TaskBodyScope | null = null; + try { + const AsyncLocalStorage = (globalThis as any).process?.getBuiltinModule?.("node:async_hooks")?.AsyncLocalStorage; + if (AsyncLocalStorage) scope = new AsyncLocalStorage(); + } catch {} + Reflect.set(globalThis, "__wmill_wf_task_body", scope); + return scope; +} + +function runTaskBody(fn: () => R): R { + const scope = taskBodyScope(); + return scope ? scope.run(true, fn) : fn(); +} + +function insideTaskBody(): boolean { + return taskBodyScope()?.getStore() === true; +} + +/** The ctx SDK calls dispatch through, or null where they run in place: outside + * a workflow, and inside a task body, which is one job and has no rounds of its + * own to dispatch or checkpoint a step in. */ +function dispatchCtx(): WorkflowCtx | null { + if (insideTaskBody()) return null; + return _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); +} + +function assertNotInsideTaskBody(what: string): void { + if (insideTaskBody()) { + throw new Error( + `${what} cannot be called from inside a task: a task runs as a single job and cannot suspend or dispatch steps. Call it from the workflow() body instead.`, + ); + } +} + export class WorkflowCtx { private completed: Record; @@ -2304,11 +2350,11 @@ export class WorkflowCtx { } export async function sleep(seconds: number): Promise { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + const ctx = dispatchCtx(); if (ctx) { return ctx._sleep(seconds) as Promise; } - // Outside workflow context, just wait locally + // Outside a workflow, or inside a task body, just wait locally await new Promise((r) => setTimeout(r, seconds * 1000)); } @@ -2319,12 +2365,15 @@ export async function sleep(seconds: number): Promise { * `fn`'s result is encoded as JSON and decoded back before it is returned, so * the round that runs the body sees the same types every replay sees: a `Date` * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape. + * + * Called from inside a task body, `fn` runs in place and nothing is + * checkpointed: a task is a single job and is never replayed. */ export async function step( name: string, fn: () => T | Promise, ): Promise>> { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + const ctx = dispatchCtx(); if (ctx) { return ctx._runInlineStep(name, fn) as Promise>>; } @@ -2342,8 +2391,9 @@ export async function step( * const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } }); * * Inside a `workflow()`, calling a task dispatches it as a step. - * Outside a workflow, the function body executes directly and - * {@link TaskOptions} — retry included — does not apply. + * Outside a workflow, or called from inside another task, the function body + * executes directly and {@link TaskOptions} — retry included — does not apply: + * a task runs as a single job, so the tasks it calls run in that same job. * * A task runs as its own job, so its result is always encoded as JSON and * decoded back before the caller sees it: a `Date` comes back as a string, a @@ -2377,7 +2427,7 @@ export function task Promise>( // .then() which throws StepSuspend and empties pending before the caller // can flush. const wrapper = function (...args: any[]) { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + const ctx = dispatchCtx(); if (ctx) { // Inside a workflow with checkpoint/replay context — dispatch as step const script = taskPath ?? taskName; @@ -2397,7 +2447,7 @@ export function task Promise>( return (async () => { let result: any; try { - result = await fn(...args); + result = await runTaskBody(() => fn(...args)); } catch (e) { if (isSuspendSignal(e, StepSuspend)) throw e; ctx._raiseStepFailure(e); @@ -2406,7 +2456,7 @@ export function task Promise>( })(); } return stepResult; - } else if (getEnv("WM_JOB_ID") && !getEnv("WM_FLOW_JOB_ID")) { + } else if (!insideTaskBody() && getEnv("WM_JOB_ID") && !getEnv("WM_FLOW_JOB_ID")) { // Inside a Windmill root job without checkpoint context — v1 HTTP dispatch // WM_FLOW_JOB_ID is set on child jobs, so we skip dispatch for those return (async () => { @@ -2456,7 +2506,8 @@ export function taskScript(path: string, options?: TaskOptions): (...args: any[] assertUsableRetry(options?.retry); const name = path.split("/").pop() || path; const wrapper = function (...args: any[]) { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + assertNotInsideTaskBody(`taskScript("${path}")`); + const ctx = dispatchCtx(); if (ctx) { const kwargs = args.length === 1 && typeof args[0] === "object" && args[0] !== null ? args[0] @@ -2482,7 +2533,8 @@ export function taskFlow(path: string, options?: TaskOptions): (...args: any[]) assertUsableRetry(options?.retry); const name = path.split("/").pop() || path; const wrapper = function (...args: any[]) { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + assertNotInsideTaskBody(`taskFlow("${path}")`); + const ctx = dispatchCtx(); if (ctx) { const kwargs = args.length === 1 && typeof args[0] === "object" && args[0] !== null ? args[0] @@ -2535,7 +2587,8 @@ export function waitForApproval(options?: { skin?: "detailed" | "minimal"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { - const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); + assertNotInsideTaskBody("waitForApproval()"); + const ctx = dispatchCtx(); if (!ctx) { throw new Error("waitForApproval can only be called inside a workflow()"); } diff --git a/typescript-client/tests/workflow_nested_task.test.ts b/typescript-client/tests/workflow_nested_task.test.ts new file mode 100644 index 0000000000..39ac2185d8 --- /dev/null +++ b/typescript-client/tests/workflow_nested_task.test.ts @@ -0,0 +1,91 @@ +/** + * A task body runs as one child job, so the SDK calls it makes cannot be + * dispatched as steps of their own. Against the real client. + * + * Run with: bun test typescript-client/tests/workflow_nested_task.test.ts + */ +import { expect, test, describe, mock, afterEach } from "bun:test"; + +mock.module("../services.gen", () => ({ + ResourceService: {}, + VariableService: {}, + JobService: {}, + HelpersService: {}, + AppService: {}, + MetricsService: {}, + OidcService: {}, + UserService: {}, + KafkaTriggerService: {}, +})); +mock.module("../core/OpenAPI", () => ({ + OpenAPI: { BASE: "http://localhost:8000/api", TOKEN: "tok" }, +})); + +const { WorkflowCtx, StepSuspend, task, taskScript, step, setWorkflowCtx } = await import( + "../client.ts" +); + +let ran: string[] = []; + +const inner = task(async function inner(x: string) { + ran.push("inner"); + return `inner:${x}`; +}); + +const outer = task(async function outer(x: string) { + ran.push("outer"); + await Promise.resolve(); + const viaTask = await inner(x); + const viaStep = await step("s", () => "stepped"); + return `${viaTask}+${viaStep}`; +}); + +const dispatching = task(async function dispatching() { + return await taskScript("f/other/script")({}); +}); + +/** Run `body` as the child round executing `key`, and hand back what it raised. */ +async function childRound(key: string, body: () => Promise): Promise { + setWorkflowCtx(new WorkflowCtx({ completed_steps: {}, _executing_key: key })); + const hung = new Promise((r) => setTimeout(() => r("hung"), 500)); + return await Promise.race([body().then(() => "returned", (e) => e), hung]); +} + +afterEach(() => { + ran = []; + setWorkflowCtx(null); +}); + +describe("SDK calls inside a task body", () => { + test("a nested task and step run in the task's own job", async () => { + const raised = await childRound("outer", async () => { + await outer("a"); + }); + + expect(raised).toBeInstanceOf(StepSuspend); + expect(raised.dispatchInfo).toMatchObject({ + mode: "step_complete", + result: "inner:a+stepped", + }); + }); + + // The workflow body keeps replaying beside the task body, and its own calls + // must stay parked: running them would execute a sibling task in this job. + test("a sibling task of the workflow body is not run", async () => { + const raised = await childRound("outer", async () => { + await Promise.all([outer("a"), inner("sibling")]); + }); + + expect(raised.dispatchInfo).toMatchObject({ mode: "step_complete" }); + expect(ran).toEqual(["outer", "inner"]); + }); + + test("a step that would need a dispatch fails instead of hanging", async () => { + const raised = await childRound("dispatching", async () => { + await dispatching(); + }); + + expect(raised).toBeInstanceOf(Error); + expect(raised.message).toContain("cannot be called from inside a task"); + }); +}); From 7627c1be8d82c298173ad4a7002483d1caee6662 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 18:47:59 +0200 Subject: [PATCH 159/174] fix: fill app ctx inputs from the viewer's session, not the request (#11698) * fix: fill raw app ctx inputs on the server, not from the request Co-Authored-By: Claude Opus 5.5 * fix: read declared ctx inputs from the deployed app at run time Co-Authored-By: Claude Opus 5.5 * test: count the revocation's cache invalidation apart from the deploy's Co-Authored-By: Claude Opus 5.5 * refactor: drop the deploy-time cache eviction trigger, rely on the cache ttl Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/tests/app_ctx_inputs.rs | 88 ++++++++++++++++ backend/windmill-api/src/apps.rs | 167 ++++++++++++++++++++++--------- 2 files changed, 209 insertions(+), 46 deletions(-) create mode 100644 backend/tests/app_ctx_inputs.rs diff --git a/backend/tests/app_ctx_inputs.rs b/backend/tests/app_ctx_inputs.rs new file mode 100644 index 0000000000..cf386adc89 --- /dev/null +++ b/backend/tests/app_ctx_inputs.rs @@ -0,0 +1,88 @@ +//! A context input a raw app runnable declares (`fields. = { type: "ctx", .. }`) is +//! filled by the server from the viewer's session. Whatever the request sends under that +//! name is discarded: a runnable filters rows on it, so a viewer who could set it would +//! read someone else's. +//! +//! Users from the `base` fixture: +//! test-user (admin, token SECRET_TOKEN) +//! test-user-2 (developer, token SECRET_TOKEN_2) + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; +const VIEWER_TOKEN: &str = "SECRET_TOKEN_2"; +const APP_PATH: &str = "u/test-user/ctx_app"; +const CONTENT: &str = "export function main(who: string) { return who }"; +// sha256 of CONTENT, the key an inline runnable is pinned under. +const CONTENT_SHA: &str = "6e198199621d57e4fed834510cb0c2692274aa4cbe976e1704fab2806be9d744"; + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +#[sqlx::test(fixtures("base"))] +async fn a_declared_ctx_input_ignores_the_value_the_viewer_sends( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + let client = reqwest::Client::new(); + + let resp = authed(client.post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": APP_PATH, + "summary": "ctx inputs", + "value": { + "runnables": { + "whoami": { + "type": "inline", + "inlineScript": { "language": "bun", "content": CONTENT }, + "fields": { "who": { "type": "ctx", "ctx": "username" } } + } + } + }, + // The policy says nothing of context inputs: the server reads the + // declaration from the deployed runnable. + "policy": { + "execution_mode": "anonymous", + "triggerables_v2": { + format!("whoami:rawscript/{CONTENT_SHA}"): { + "static_inputs": {}, + "one_of_inputs": {} + } + } + } + })) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + + // Both spellings a viewer could send: a forged literal, and no argument at all. + for args in [json!({ "who": "test-user" }), json!({})] { + let resp = authed( + client.post(format!("{ws}/apps_u/execute_component/{APP_PATH}")), + VIEWER_TOKEN, + ) + .json(&json!({ + "component": "whoami", + "args": args, + "raw_code": { "language": "bun", "content": CONTENT } + })) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + let job_id: uuid::Uuid = resp.text().await?.parse()?; + + let queued: serde_json::Value = sqlx::query_scalar("SELECT args FROM v2_job WHERE id = $1") + .bind(job_id) + .fetch_one(&db) + .await?; + assert_eq!(queued["who"], json!("test-user-2"), "sent {args}"); + } + + Ok(()) +} diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 430aa801a0..2c2179d830 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4393,10 +4393,55 @@ lazy_static! { /// change or deletion, which don't bump the version — see /// `invalidate_app_policy_cache` and `process_notify_event`); /// - the entry is within its TTL (backstop for a missed event or a restart). - static ref APP_POLICY_CACHE: cache::Cache<(String, String), (Option, Arc, std::time::Instant)> = + static ref APP_POLICY_CACHE: cache::Cache<(String, String), (Option, Arc, std::time::Instant)> = cache::Cache::new(1000); } +/// What `execute_component` runs a deployed app against. +struct DeployedPolicy { + policy: Policy, + /// Runnable id -> argument -> context property (`username`, `email`, `groups`, + /// `workspace`, `author`), for every `ctx` field the app's runnables declare. Read + /// from the deployed app rather than taken from the request, which only says what + /// the browser chose to send: a runnable filters rows on these. A deploy that + /// changes them under an unchanged policy evicts nothing, so a caller still naming + /// the replaced version keeps the previous declarations until the entry's TTL. + ctx_inputs: HashMap>, +} + +async fn deployed_ctx_inputs( + db: &DB, + w_id: &str, + path: &str, +) -> Result>> { + let runnables = sqlx::query_scalar::<_, Option>>( + "SELECT app_version.value -> 'runnables' FROM app + JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] + WHERE app.path = $1 AND app.workspace_id = $2", + ) + .bind(path) + .bind(w_id) + .fetch_optional(db) + .await? + .flatten(); + let Some(serde_json::Value::Object(runnables)) = runnables.map(|r| r.0) else { + return Ok(HashMap::new()); + }; + Ok(runnables + .into_iter() + .filter_map(|(id, runnable)| { + let ctx_inputs: HashMap = runnable + .get("fields")? + .as_object()? + .iter() + .filter(|(_, f)| f.get("type").and_then(|t| t.as_str()) == Some("ctx")) + .filter_map(|(arg, f)| Some((arg.clone(), f.get("ctx")?.as_str()?.to_string()))) + .collect(); + (!ctx_inputs.is_empty()).then_some((id, ctx_inputs)) + }) + .collect()) +} + /// Backstop time-to-live for an [`APP_POLICY_CACHE`] entry — the maximum a policy /// change can go unreflected if its invalidation event is never consumed. const APP_POLICY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300); @@ -4449,7 +4494,7 @@ async fn execute_component( _ => {} }; - let arc_policy: Arc; + let arc_policy: Arc; let policy_triggerables_default = Default::default(); // Preview mode means the request was issued from the editor; the editing // user is already trusted by the policy check, so client-supplied `tag` @@ -4532,7 +4577,7 @@ async fn execute_component( // - The policy and triggerables are fetched from the database. // - In case of inline script, if an entry exists in the `app_script` table, push // an `AppScript` job payload, as in (.1) otherwise. - let (policy, policy_triggerables) = match payload { + let (policy, policy_triggerables, ctx_inputs) = match payload { // 1. "preview" mode. ExecuteApp { force_viewer_static_fields: Some(static_inputs), @@ -4551,6 +4596,7 @@ async fn execute_component( sensitive_inputs: force_viewer_sensitive_inputs.unwrap_or_default(), tag: None, }, + None, ), // 2. "run" mode. _ => { @@ -4580,9 +4626,12 @@ async fn execute_component( .map(|(_, p, _)| p); let policy = if let Some(p) = fresh { arc_policy = p; - &*arc_policy + &arc_policy.policy } else { - arc_policy = Arc::new(policy_fut.await?); + arc_policy = Arc::new(DeployedPolicy { + policy: policy_fut.await?, + ctx_inputs: deployed_ctx_inputs(&db, &w_id, path).await?, + }); APP_POLICY_CACHE.insert( cache_key, ( @@ -4591,7 +4640,7 @@ async fn execute_component( std::time::Instant::now(), ), ); - &*arc_policy + &arc_policy.policy }; // Caller-supplied inline code (`raw_code`), with or without an @@ -4653,7 +4702,11 @@ async fn execute_component( }) .ok_or_else(|| Error::BadRequest(format!("Path {path} forbidden by policy")))?; - (policy, policy_triggerables) + ( + policy, + policy_triggerables, + arc_policy.ctx_inputs.get(&payload.component), + ) } }; @@ -4731,6 +4784,7 @@ async fn execute_component( let (mut args, job_id) = build_args( policy, policy_triggerables, + ctx_inputs, payload.args, opt_authed.as_ref(), &user_db, @@ -6161,6 +6215,54 @@ fn policy_granted_viewer(authed: &ApiAuthed) -> ApiAuthed { ApiAuthed { scopes: None, ..authed.clone() } } +/// The value of an app context property for this run. Everything about the viewer comes +/// from `authed`, the session the request authenticated with; an anonymous run has none +/// and gets `null`. +async fn ctx_value( + prop: &str, + policy: &Policy, + authed: Option<&ApiAuthed>, + w_id: &str, + db: &DB, +) -> Result> { + let value = match prop { + "username" => authed.map(|a| serde_json::to_value(a.display_username())), + "email" => authed.map(|a| serde_json::to_value(&a.email)), + "workspace" => Some(serde_json::to_value(w_id)), + "groups" => authed.map(|a| serde_json::to_value(&a.groups)), + // Same rule as `get_on_behalf_of`: the stored address, derived only when absent. + "author" => { + let author = match ( + policy.on_behalf_of_email.as_deref(), + policy.on_behalf_of.as_deref(), + ) { + (Some(email), _) => Some(email.to_string()), + (None, Some(permissioned_as)) => Some( + windmill_common::users::get_email_from_permissioned_as( + permissioned_as, + w_id, + db, + ) + .await?, + ), + (None, None) => None, + }; + Some(serde_json::to_value(&author)) + } + _ => { + return Err(Error::BadRequest(format!( + "context variable {} not allowed", + prop + ))) + } + }; + Ok(to_raw_value( + &value.unwrap_or(Ok(serde_json::Value::Null)).map_err(|e| { + Error::internal_err(format!("failed to serialize ctx variable {}: {}", prop, e)) + })?, + )) +} + async fn build_args( policy: &Policy, PolicyTriggerableInputs { @@ -6170,6 +6272,7 @@ async fn build_args( sensitive_inputs, .. }: &PolicyTriggerableInputs, + ctx_inputs: Option<&HashMap>, mut args: HashMap>, authed: Option<&ApiAuthed>, user_db: &UserDB, @@ -6179,6 +6282,16 @@ async fn build_args( let mut job_id: Option = None; let mut safe_args = HashMap::>::new(); + // Before anything reads `args`: a context input the runnable declares is never taken + // from the request, or a viewer could name someone else as themselves. + for (k, prop) in ctx_inputs.into_iter().flatten() { + args.remove(k); + safe_args.insert( + k.to_string(), + ctx_value(prop, policy, authed, w_id, db).await?, + ); + } + // tracing::error!("{:?}", allow_user_resources); for k in allow_user_resources.iter() { if let Some(arg_val) = args.get(k) { @@ -6270,47 +6383,9 @@ async fn build_args( if arg_str.starts_with("\"$ctx:") { let prop = arg_str.trim_start_matches("\"$ctx:").trim_end_matches("\""); - let value = match prop { - "username" => authed - .as_ref() - .map(|a| serde_json::to_value(a.display_username())), - "email" => authed.as_ref().map(|a| serde_json::to_value(&a.email)), - "workspace" => Some(serde_json::to_value(&w_id)), - "groups" => authed.as_ref().map(|a| serde_json::to_value(&a.groups)), - // Same rule as `get_on_behalf_of`: the stored address, derived only when absent. - "author" => { - let author = match ( - policy.on_behalf_of_email.as_deref(), - policy.on_behalf_of.as_deref(), - ) { - (Some(email), _) => Some(email.to_string()), - (None, Some(permissioned_as)) => Some( - windmill_common::users::get_email_from_permissioned_as( - permissioned_as, - w_id, - db, - ) - .await?, - ), - (None, None) => None, - }; - Some(serde_json::to_value(&author)) - } - _ => { - return Err(Error::BadRequest(format!( - "context variable {} not allowed", - prop - ))) - } - }; safe_args.insert( k.to_string(), - to_raw_value(&value.unwrap_or(Ok(serde_json::Value::Null)).map_err(|e| { - Error::internal_err(format!( - "failed to serialize ctx variable for {}: {}", - k, e - )) - })?), + ctx_value(prop, policy, authed, w_id, db).await?, ); } else if !arg_str.contains("\"$var:") && !arg_str.contains("\"$res:") { safe_args.insert(k.to_string(), v); From a92b29dae6950d29c2b8970e07d055fa02ff979e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 19:07:23 +0200 Subject: [PATCH 160/174] fix: load the s3 explorer table lazily to keep monaco out of the app shell (#11701) Co-authored-by: Claude Opus 5.5 --- .../lib/components/S3FilePickerInner.svelte | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/S3FilePickerInner.svelte b/frontend/src/lib/components/S3FilePickerInner.svelte index 5f8dcfc3d7..89c23e84ae 100644 --- a/frontend/src/lib/components/S3FilePickerInner.svelte +++ b/frontend/src/lib/components/S3FilePickerInner.svelte @@ -26,7 +26,6 @@ import Toggle from './Toggle.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' - import DBTable from './DBTable.svelte' import type { IDbTableOps } from './dbOps' import { resource } from 'runed' import { staticDbTableOps } from './staticDbTableOps' @@ -1160,6 +1159,12 @@ }) +{#snippet tableLoading()} +
+ Loading table... +
+{/snippet} + {#if workspaceSettingsInitialized === false}
{#if fromWorkspaceSettings} @@ -1522,17 +1527,20 @@ {#if showTable}
{#if currentTable?.ops} - {#key currentTable.ops} - - {/key} + + {#await import('./DBTable.svelte')} + {@render tableLoading()} + {:then DBTable} + {#key currentTable.ops} + + {/key} + {/await} {:else if tableOps.error}
Could not read this file as a table: {tableOps.error.message}
{:else} -
- Loading table... -
+ {@render tableLoading()} {/if}
{:else} From 14519884e4c9aa4d0a006cbc019a776f9a038967 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 19:28:05 +0200 Subject: [PATCH 161/174] feat: support array arguments in snowflake scripts (#11702) * feat: support array arguments in snowflake scripts Co-Authored-By: Claude Opus 5.5 * docs: cast the parsed snowflake list to array in the guidance Co-Authored-By: Claude Opus 5.5 * fix: parse a json array default of a sql list argument as a list Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../parsers/windmill-parser-sql/src/lib.rs | 45 ++++++++++++++++++- .../windmill-worker/src/snowflake_executor.rs | 16 ++++++- cli/src/guidance/skills.gen.ts | 21 +++++++++ system_prompts/auto-generated/prompts.ts | 21 +++++++++ system_prompts/auto-generated/script.md | 21 +++++++++ .../skills/write-script-snowflake/SKILL.md | 21 +++++++++ system_prompts/languages/snowflake.md | 21 +++++++++ 7 files changed, 164 insertions(+), 2 deletions(-) diff --git a/backend/parsers/windmill-parser-sql/src/lib.rs b/backend/parsers/windmill-parser-sql/src/lib.rs index 10929e409a..341906a3e3 100644 --- a/backend/parsers/windmill-parser-sql/src/lib.rs +++ b/backend/parsers/windmill-parser-sql/src/lib.rs @@ -235,7 +235,7 @@ lazy_static::lazy_static! { // -- $name (type) = default static ref RE_ARG_DUCKDB: Regex = Regex::new(r#"(?m)^-- \$(\w+) \(([A-Za-z0-9_\[\]]+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap(); - static ref RE_ARG_SNOWFLAKE: Regex = Regex::new(r#"(?m)^-- \? (\w+) \((\w+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap(); + static ref RE_ARG_SNOWFLAKE: Regex = Regex::new(r#"(?m)^-- \? (\w+) \((\w+(?:\[\])?)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap(); static ref RE_ARG_MSSQL: Regex = Regex::new(r#"(?m)^-- @(?:P|p)\d+ (\w+) \((\w+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap(); @@ -254,6 +254,12 @@ fn parsed_default(parsed_typ: &Typ, default: String) -> Option= 2 && default.starts_with("'") && default.ends_with("'") => { Some(json!(&default[1..default.len() - 1])) } + Typ::List(_) => Some( + serde_json::from_str::(&default) + .ok() + .filter(|v| v.is_array()) + .unwrap_or_else(|| json!(default)), + ), _ => Some(json!(default)), } } @@ -1039,7 +1045,11 @@ pub fn parse_duckdb_typ(typ: &str) -> Typ { } pub fn parse_snowflake_typ(typ: &str) -> Typ { + if let Some(base_typ) = typ.strip_suffix("[]") { + return Typ::List(Box::new(parse_snowflake_typ(base_typ))); + } match typ { + "array" => Typ::List(Box::new(Typ::Unknown)), "varchar" | "text" | "string" | "char" | "character" => Typ::Str(None), "binary" | "varbinary" => Typ::Bytes, "date" | "time" | "datetime" | "timestamp" | "timestamp_ntz" | "timestamp_ltz" @@ -1591,6 +1601,39 @@ SELECT ?; Ok(()) } + #[test] + fn test_parse_snowflake_array_types() -> anyhow::Result<()> { + let code = r#" +-- ? groups (varchar[]) +-- ? ids (int[]) = [1, 2] +-- ? anything (ARRAY) +SELECT 1; +"#; + let args = parse_snowflake_sig(code)?.args; + assert_eq!(args[1].default, Some(json!([1, 2]))); + let got = args + .iter() + .map(|a| (a.name.as_str(), a.otyp.as_deref(), &a.typ)) + .collect::>(); + assert_eq!( + got, + vec![ + ( + "groups", + Some("varchar[]"), + &Typ::List(Box::new(Typ::Str(None))) + ), + ("ids", Some("int[]"), &Typ::List(Box::new(Typ::Int))), + ( + "anything", + Some("array"), + &Typ::List(Box::new(Typ::Unknown)) + ), + ] + ); + Ok(()) + } + #[test] fn test_parse_mssql_sig() -> anyhow::Result<()> { let code = r#" diff --git a/backend/windmill-worker/src/snowflake_executor.rs b/backend/windmill-worker/src/snowflake_executor.rs index 24b07eacb9..411dd520b9 100644 --- a/backend/windmill-worker/src/snowflake_executor.rs +++ b/backend/windmill-worker/src/snowflake_executor.rs @@ -873,7 +873,8 @@ fn convert_typ_val(arg_t: String, arg_v: Value) -> Value { let mut v = arg_v; if !v.is_string() { - // if not string, convert to string for api request + // The SQL API has no ARRAY/VARIANT binding type: a list or object + // is bound as its JSON text, which the query reads with PARSE_JSON(?). v = json!(v.to_string()); } @@ -935,3 +936,16 @@ fn parse_val(value: &Value, typ: &str) -> Value { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_arg_binds_as_json_text() { + assert_eq!( + convert_typ_val("varchar[]".to_string(), json!(["sales", "g/ops"])), + json!({ "type": "TEXT", "value": r#"["sales","g/ops"]"# }) + ); + } +} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index fff7df1153..e08db46f92 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5625,6 +5625,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` +## Array parameters + +Declare a list with a \`[]\` suffix on the element type (\`(varchar[])\`, +\`(int[])\`), or \`(array)\` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +\`PARSE_JSON(?)\`, and cast to \`ARRAY\` where a function expects one. + +\`\`\`sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +\`\`\` + +Or join on the elements with \`FLATTEN\`: + +\`\`\`sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +\`\`\` + ## Receiving an S3Object as a script parameter Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 393b724f6b..83513de11a 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -5815,6 +5815,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` +## Array parameters + +Declare a list with a \`[]\` suffix on the element type (\`(varchar[])\`, +\`(int[])\`), or \`(array)\` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +\`PARSE_JSON(?)\`, and cast to \`ARRAY\` where a function expects one. + +\`\`\`sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +\`\`\` + +Or join on the elements with \`FLATTEN\`: + +\`\`\`sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +\`\`\` + ## Receiving an S3Object as a script parameter Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index a4064c3d9e..d178ddb6c0 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1409,6 +1409,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; ``` +## Array parameters + +Declare a list with a `[]` suffix on the element type (`(varchar[])`, +`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one. + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +``` + +Or join on the elements with `FLATTEN`: + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +``` + ## Receiving an S3Object as a script parameter Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for diff --git a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md index 8c438e7038..cd79a1f5d4 100644 --- a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md @@ -84,6 +84,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; ``` +## Array parameters + +Declare a list with a `[]` suffix on the element type (`(varchar[])`, +`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one. + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +``` + +Or join on the elements with `FLATTEN`: + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +``` + ## Receiving an S3Object as a script parameter Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for diff --git a/system_prompts/languages/snowflake.md b/system_prompts/languages/snowflake.md index 8ded76eb3c..b7798e3461 100644 --- a/system_prompts/languages/snowflake.md +++ b/system_prompts/languages/snowflake.md @@ -10,6 +10,27 @@ Name the parameters by adding comments before the statement: SELECT * FROM users WHERE name = ? AND age > ?; ``` +## Array parameters + +Declare a list with a `[]` suffix on the element type (`(varchar[])`, +`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array +directly, so Windmill binds the list as JSON text: wrap the placeholder with +`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one. + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY); +``` + +Or join on the elements with `FLATTEN`: + +```sql +-- ? groups (varchar[]) +SELECT o.* FROM orders o +JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group; +``` + ## Receiving an S3Object as a script parameter Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for From 26afeecbd5b0a11c084813182e92805a15d21bbb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 19:28:38 +0200 Subject: [PATCH 162/174] feat(cli): declare app sandbox and frontend api scopes in app yaml (#11703) Co-authored-by: Claude Opus 5.5 --- cli/src/commands/app/app.ts | 130 +++++++++++++++++- cli/src/commands/app/lint.ts | 7 + cli/src/commands/app/raw_apps.ts | 21 ++- cli/src/commands/sync/sync.ts | 4 +- cli/src/guidance/skills.gen.ts | 12 ++ cli/test/app_isolation_unit.test.ts | 60 ++++++++ system_prompts/auto-generated/prompts.ts | 4 + .../auto-generated/skills/raw-app/SKILL.md | 12 ++ system_prompts/base/raw-app.md | 14 ++ 9 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 cli/test/app_isolation_unit.test.ts diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 0d4ad634c8..470f0f4b11 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -32,6 +32,8 @@ export interface AppFile { guests?: boolean; value: any; public?: boolean; + sandbox?: boolean; + frontend_api?: string[]; summary: string; policy: Policy; // Mirrors granular ACLs on the app path. Omitted from app.yaml when no @@ -160,6 +162,100 @@ export function executionModeForPush( } return deployedPolicy?.execution_mode === "viewer" ? "viewer" : "publisher"; } +/** Mirrors `FRONTEND_SDK_ALLOWED_SCOPES` in the backend's `apps.rs`, which stays + * the authority: this copy only fails a push before it bundles. */ +export const FRONTEND_API_SCOPES = [ + "jobs:run", + "jobs:read", + "users:read", + "resources:read", + "variables:read", + "flow_conversations:read", + "flow_conversations:write", +]; + +export type AppIsolation = { sandbox: boolean; frontendApi: string[] }; + +/** Write `sandbox` and `frontend_api` on a pulled app, only when they are on, so + * an app using neither keeps the file it had. Scopes of an unsandboxed app are + * inert and stay out: a file stating them without `sandbox` does not push. */ +export function markIsolationFromPolicy(app: any, rawApp: boolean) { + if (app?.["policy"]?.["sandbox"] !== true) { + return; + } + app.sandbox = true; + const scopes = app.policy.frontend_sdk_scopes; + if (rawApp && Array.isArray(scopes) && scopes.length > 0) { + app.frontend_api = scopes; + } +} + +/** The isolation the tracked file states, or `undefined` when it has no `sandbox` + * key and the deployed policy carries over. A file that has the key states both + * fields: `sandbox: false` turns isolation off, and a missing `frontend_api` + * means no scopes. */ +export function statedIsolation( + localApp: any, + rawApp: boolean, +): AppIsolation | undefined { + const sandbox = localApp?.["sandbox"]; + const scopes = localApp?.["frontend_api"] ?? []; + if (sandbox !== undefined && typeof sandbox !== "boolean") { + throw new Error("'sandbox' must be true or false"); + } + if ( + !Array.isArray(scopes) || + scopes.some((s: unknown) => typeof s !== "string") + ) { + throw new Error("'frontend_api' must be a list of scopes"); + } + if (scopes.length > 0 && !rawApp) { + throw new Error("'frontend_api' is only available to full-code apps"); + } + const seen = new Set(); + for (const scope of scopes) { + if (!FRONTEND_API_SCOPES.includes(scope)) { + throw new Error( + `Invalid frontend_api scope '${scope}'. Allowed scopes: ${FRONTEND_API_SCOPES.join(", ")}`, + ); + } + if (seen.has(scope)) { + throw new Error(`Duplicate frontend_api scope '${scope}'`); + } + seen.add(scope); + } + if (scopes.length > 0 && sandbox !== true) { + throw new Error( + "'frontend_api' requires 'sandbox: true': an unsandboxed app gets no frontend API token", + ); + } + return sandbox === undefined ? undefined : { sandbox, frontendApi: scopes }; +} + +/** Whether the deployed policy already has the stated isolation. The file's two + * keys are kept out of the superset comparison against the deployed app, which + * could not see a key the file dropped. */ +export function isolationUpToDate( + isolation: AppIsolation | undefined, + deployedPolicy: Policy | undefined, + rawApp: boolean, +): boolean { + if (!isolation) { + return true; + } + if ((deployedPolicy?.sandbox === true) !== isolation.sandbox) { + return false; + } + if (!rawApp) { + return true; + } + const deployed = deployedPolicy?.frontend_sdk_scopes ?? []; + return ( + deployed.length === isolation.frontendApi.length && + deployed.every((s, i) => s === isolation.frontendApi[i]) + ); +} + export async function pushApp( workspace: string, remotePath: string, @@ -198,6 +294,7 @@ export async function pushApp( } const path = localPath + "app.yaml"; const localApp = (await yamlParseFile(path)) as AppFile; + const isolation = statedIsolation(localApp, false); replaceInlineScripts(localApp.value, localPath, true); // On create the backend applies folder defaults, so there is nothing to preserve. @@ -210,17 +307,26 @@ export async function pushApp( localApp, remotePath, executionModeForPush(localApp, deployedPolicy), - basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of) + basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of), + isolation ); // extra_perms goes through /acls/* — strip from the body so a perms-only // edit never bumps the app version (see applyExtraPermsDiff for details). - const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & { + const { + extra_perms: localPerms, + sandbox: _sandbox, + frontend_api: _frontendApi, + ...localAppBody + } = localApp as AppFile & { extra_perms?: Record; }; if (app) { - if (isSuperset(localAppBody, app)) { + if ( + isSuperset(localAppBody, app) && + isolationUpToDate(isolation, deployedPolicy, false) + ) { log.info(colors.green(`App ${remotePath} is up to date`)); } else { log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); @@ -269,12 +375,13 @@ export async function generatingPolicy( app: any, path: string, executionMode: AppExecutionMode, - base: Policy | undefined + base: Policy | undefined, + isolation?: AppIsolation ) { log.info(colors.gray(`Generating fresh policy for app ${path}...`)); try { app.policy = await windmillUtils.updatePolicy(app.value, base); - finalizeDerivedPolicy(app.policy, executionMode); + finalizeDerivedPolicy(app.policy, executionMode, isolation); } catch (e) { log.error(colors.red(`Error generating policy for app ${path}: ${e}`)); throw e; @@ -332,10 +439,21 @@ export function preserveOnBehalfOfFields( * deployed app would keep being able to run runnables this push removed. */ export function finalizeDerivedPolicy( policy: Policy, - executionMode: AppExecutionMode + executionMode: AppExecutionMode, + isolation?: AppIsolation, + rawApp: boolean = false ) { policy.triggerables = undefined; policy.execution_mode = executionMode; + if (isolation) { + // `false` is sent rather than left out: the server reads an omitted value as + // "no opinion" and may sandbox the app anyway, hiding that the file was refused. + policy.sandbox = isolation.sandbox; + if (rawApp) { + policy.frontend_sdk_scopes = + isolation.frontendApi.length > 0 ? isolation.frontendApi : undefined; + } + } } async function list(opts: GlobalOptions & { includeDraftOnly?: boolean; json?: boolean }) { diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index 2276a77cad..36f3187cd3 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -9,6 +9,7 @@ import { GlobalOptions } from "../../types.ts"; import { createBundle, detectFrameworks } from "./bundle.ts"; import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; import { loadRunnablesFromBackend } from "./raw_apps.ts"; +import { statedIsolation } from "./app.ts"; import { getFolderSuffix, hasFolderSuffix, @@ -42,6 +43,12 @@ function validateRawAppYaml(appData: any): { errors.push("Field 'summary' must be a string"); } + try { + statedIsolation(appData, true); + } catch (error: any) { + errors.push(error.message); + } + // Note: 'runnables' is no longer required in raw_app.yaml // Runnables can be stored in separate files in the backend folder diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 16179d4662..4c3d8d5215 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -20,13 +20,16 @@ import { deepEqual, readTextFile } from "../../utils/utils.ts"; import { type AppExecutionMode, + type AppIsolation, basePolicy, executionModeForPush, finalizeDerivedPolicy, + isolationUpToDate, markAccessFromPolicy, preserveOnBehalfOfFields, replaceInlineScripts, repopulateFields, + statedIsolation, } from "./app.ts"; import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; import { buildPermissionedAsContext } from "../../core/permissioned_as.ts"; @@ -49,6 +52,8 @@ export interface AppFile { runnables?: any; custom_path?: string; public?: boolean; + sandbox?: boolean; + frontend_api?: string[]; summary: string; policy: Policy; data?: { @@ -402,6 +407,7 @@ export async function pushRawApp( } const appFilePath = localPath + "raw_app.yaml"; const localApp = (await yamlParseFile(appFilePath)) as AppFile; + const isolation = statedIsolation(localApp, true); // Load runnables from separate YAML files in the backend folder // Falls back to reading from raw_app.yaml if no separate files exist (backward compat) @@ -454,6 +460,7 @@ export async function pushRawApp( deployedPolicy, !!preserveFields.preserve_on_behalf_of, ), + isolation, ); const files = await collectAppFiles(localPath); @@ -485,14 +492,21 @@ export async function pushRawApp( // to `.raw_app.json`, not `.app.json`. The backend granular_acls // handler routes "raw_app" to the `app` table (where v2 raw apps actually // live) while still dispatching DeployedObject::RawApp for git-sync. - const { extra_perms: localPerms, ...localAppNoPerms } = localApp as AppFile & { + const { + extra_perms: localPerms, + sandbox: _sandbox, + frontend_api: _frontendApi, + ...localAppNoPerms + } = localApp as AppFile & { extra_perms?: Record; }; if (app) { // Check both metadata/runnables AND files for changes // Files need separate comparison because isSuperset only checks if local keys exist in remote - const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app); + const metadataUpToDate = + isSuperset({ ...localAppNoPerms, runnables }, app) && + isolationUpToDate(isolation, deployedPolicy, true); const filesUpToDate = deepEqual(files, app.value?.files); if (metadataUpToDate && filesUpToDate) { log.info(colors.green(`App ${remotePath} is up to date`)); @@ -560,11 +574,12 @@ export async function generatingPolicy( path: string, executionMode: AppExecutionMode, base: Policy | undefined, + isolation?: AppIsolation, ) { log.info(colors.gray(`Generating fresh policy for app ${path}...`)); try { app.policy = await windmillUtils.updateRawAppPolicy(app.runnables, base); - finalizeDerivedPolicy(app.policy, executionMode); + finalizeDerivedPolicy(app.policy, executionMode, isolation, true); } catch (e) { log.error(colors.red(`Error generating policy for app ${path}: ${e}`)); throw e; diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 54887028f0..b9e373abad 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -149,7 +149,7 @@ import { extractCurrentMapping, } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; import { generateFlowLockInternal } from "../flow/flow_metadata.ts"; -import { markAccessFromPolicy } from "../app/app.ts"; +import { markAccessFromPolicy, markIsolationFromPolicy } from "../app/app.ts"; import { APP_BACKEND_FOLDER, generateAppLocksInternal, @@ -1359,6 +1359,7 @@ export function ZipFSElement( } markAccessFromPolicy(app); + markIsolationFromPolicy(app, false); app.policy = undefined; yield { isDirectory: false, @@ -1377,6 +1378,7 @@ export function ZipFSElement( throw error; } markAccessFromPolicy(rawApp); + markIsolationFromPolicy(rawApp, true); // console.log("rawApp", rawApp); rawApp.policy = undefined; // custom_path is derived from the file path, don't store it diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index e08db46f92..8491e837e3 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6840,6 +6840,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. \`public: true\` in \`raw_app.yaml\` deploys the app for anonymous users, and \`guests: true\` for guests. Remove the line and the next push closes the app again. +## Sandbox isolation and frontend API access + +By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +In \`raw_app.yaml\`: + +\`\`\`yaml +sandbox: true +frontend_api: [jobs:run, users:read] +\`\`\` + +A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise. + ## Data Tables Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table. diff --git a/cli/test/app_isolation_unit.test.ts b/cli/test/app_isolation_unit.test.ts new file mode 100644 index 0000000000..201c4a52a4 --- /dev/null +++ b/cli/test/app_isolation_unit.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { + finalizeDerivedPolicy, + isolationUpToDate, + markIsolationFromPolicy, + statedIsolation, +} from "../src/commands/app/app.ts"; + +function pushed(file: any, deployed: any, rawApp = true) { + const policy = { ...deployed }; + finalizeDerivedPolicy(policy, "publisher", statedIsolation(file, rawApp), rawApp); + return policy; +} + +test("a pull writes isolation only when it is on, and a push deploys what was pulled", () => { + const off: any = { policy: { sandbox: false, frontend_sdk_scopes: ["jobs:run"] } }; + markIsolationFromPolicy(off, true); + expect("sandbox" in off).toBe(false); + expect("frontend_api" in off).toBe(false); + + const on: any = { policy: { sandbox: true, frontend_sdk_scopes: ["jobs:run"] } }; + markIsolationFromPolicy(on, true); + expect(on.sandbox).toBe(true); + expect(on.frontend_api).toEqual(["jobs:run"]); + // Into a workspace where the app does not exist yet. + const fresh = pushed(on, undefined); + expect(fresh.sandbox).toBe(true); + expect(fresh.frontend_sdk_scopes).toEqual(["jobs:run"]); +}); + +test("the file wins when it states sandbox, the deployed policy carries over when it does not", () => { + const deployed = { sandbox: true, frontend_sdk_scopes: ["jobs:run"] }; + + const silent = pushed({}, deployed); + expect(silent.sandbox).toBe(true); + expect(silent.frontend_sdk_scopes).toEqual(["jobs:run"]); + expect(isolationUpToDate(statedIsolation({}, true), deployed, true)).toBe(true); + + // A file with the key states the scopes too: none here. + const narrowed = pushed({ sandbox: true }, deployed); + expect(narrowed.sandbox).toBe(true); + expect(narrowed.frontend_sdk_scopes).toBeUndefined(); + expect(isolationUpToDate(statedIsolation({ sandbox: true }, true), deployed, true)).toBe(false); + + const opened = pushed({ sandbox: false }, deployed); + expect(opened.sandbox).toBe(false); + expect(opened.frontend_sdk_scopes).toBeUndefined(); +}); + +test("scopes are refused without sandbox, outside the allowlist, or repeated", () => { + expect(() => statedIsolation({ frontend_api: ["jobs:run"] }, true)).toThrow("requires 'sandbox: true'"); + expect(() => statedIsolation({ sandbox: false, frontend_api: ["jobs:run"] }, true)).toThrow( + "requires 'sandbox: true'", + ); + expect(() => statedIsolation({ sandbox: true, frontend_api: ["apps:write"] }, true)).toThrow("Invalid"); + expect(() => statedIsolation({ sandbox: true, frontend_api: ["jobs:run", "jobs:run"] }, true)).toThrow( + "Duplicate", + ); + expect(() => statedIsolation({ sandbox: true, frontend_api: ["jobs:run"] }, false)).toThrow("full-code"); +}); diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 83513de11a..1c6541e155 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1116,6 +1116,10 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. When a deploy widens who may open the app, tell the user in plain words. +## Sandbox isolation and frontend API access + +By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. + ## Data Tables Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table. diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 92bfe68aab..8d7788dd94 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -471,6 +471,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. `public: true` in `raw_app.yaml` deploys the app for anonymous users, and `guests: true` for guests. Remove the line and the next push closes the app again. +## Sandbox isolation and frontend API access + +By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +In `raw_app.yaml`: + +```yaml +sandbox: true +frontend_api: [jobs:run, users:read] +``` + +A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise. + ## Data Tables Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 3d8e0d3e86..dea8ebd21e 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -239,6 +239,20 @@ Either one lets those people run the app's backend runnables, so never open an a When a deploy widens who may open the app, tell the user in plain words. +## Sandbox isolation and frontend API access + +By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. + +In `raw_app.yaml`: + +```yaml +sandbox: true +frontend_api: [jobs:run, users:read] +``` + +A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise. + + ## Data Tables Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table. From 36ea2da0bfaa5ebd3ee2d63eae2395f6f54172c9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 19:30:29 +0200 Subject: [PATCH 163/174] warn when a raw app sql viewer-identity input is not a ctx field (#11704) Co-authored-by: Claude Opus 5.5 --- cli/src/commands/app/lint.ts | 46 +++++++++++++++++++ .../app_lint_viewer_identity_unit.test.ts | 24 ++++++++++ .../RawAppInlineScriptRunnable.svelte | 9 ++++ .../raw_apps/RawAppInputsSpecEditor.svelte | 11 ++++- frontend/src/lib/infer.ts | 2 +- 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 cli/test/app_lint_viewer_identity_unit.test.ts diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index 36f3187cd3..c90a22105d 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -9,6 +9,7 @@ import { GlobalOptions } from "../../types.ts"; import { createBundle, detectFrameworks } from "./bundle.ts"; import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; import { loadRunnablesFromBackend } from "./raw_apps.ts"; +import { inferSchema } from "../../utils/metadata.ts"; import { statedIsolation } from "./app.ts"; import { getFolderSuffix, @@ -55,6 +56,50 @@ function validateRawAppYaml(appData: any): { return { errors, warnings }; } +const SQL_LANGUAGES = new Set([ + "postgresql", + "mysql", + "bigquery", + "snowflake", + "mssql", + "oracledb", + "duckdb", +]); + +// The viewer properties a `ctx` field can supply. The Inputs tab of the app +// editor hints on the same names (RawAppInlineScriptRunnable.svelte). +const VIEWER_IDENTITY_ARGS = ["username", "email", "groups"]; + +/** + * A SQL runnable argument named after a viewer property is most often a + * per-viewer filter. Left as a plain input it takes whatever the browser + * sends, so it has to be a `ctx` field for the server to fill it. + */ +export async function viewerIdentityInputWarnings( + runnables: Record, +): Promise { + const warnings: string[] = []; + for (const [id, runnable] of Object.entries(runnables)) { + const { language, content } = runnable?.inlineScript ?? {}; + if (!SQL_LANGUAGES.has(language) || typeof content !== "string") { + continue; + } + const { schema } = await inferSchema(language, content, {}, id); + for (const arg of VIEWER_IDENTITY_ARGS) { + const field = runnable.fields?.[arg]; + const serverFilled = field?.ctx !== undefined || + field?.value !== undefined; + if (arg in (schema?.properties ?? {}) && !serverFilled) { + warnings.push( + `Runnable '${id}': argument '${arg}' is a frontend input, so a viewer can send any value. ` + + `If it identifies the viewer, set 'fields: { ${arg}: { ctx: ${arg} } }' in backend/${id}.yaml (next to 'type: inline') so the server fills it.`, + ); + } + } + } + return warnings; +} + /** * Validates that runnables exist either in backend/*.yaml files or in raw_app.yaml */ @@ -84,6 +129,7 @@ async function validateRunnables( " - Or a 'runnables' field in raw_app.yaml (legacy format)", ); } else if (hasBackendRunnables) { + warnings.push(...await viewerIdentityInputWarnings(runnablesFromBackend)); log.info( colors.gray( ` Found ${ diff --git a/cli/test/app_lint_viewer_identity_unit.test.ts b/cli/test/app_lint_viewer_identity_unit.test.ts new file mode 100644 index 0000000000..f328ffe4e2 --- /dev/null +++ b/cli/test/app_lint_viewer_identity_unit.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +// app.ts first: it is the entry the CLI loads lint.ts through, and loading +// lint.ts ahead of it trips their import cycle. +import "../src/commands/app/app.ts"; +import { viewerIdentityInputWarnings } from "../src/commands/app/lint.ts"; + +const sql = (content: string, fields?: Record) => ({ + type: "inline", + inlineScript: { language: "duckdb", content }, + ...(fields ? { fields } : {}), +}); + +test("app lint warns on a SQL viewer-identity argument the server does not fill", async () => { + const content = + "-- $username (text)\n-- $limit (int)\nSELECT * FROM orders WHERE owner = $username LIMIT $limit"; + + const warnings = await viewerIdentityInputWarnings({ + open: sql(content), + filled: sql(content, { username: { ctx: "username" } }), + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Runnable 'open': argument 'username'"); +}); diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte index 200e76d85b..ddbdae5bf3 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte @@ -15,6 +15,7 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import Tabs from '../common/tabs/Tabs.svelte' import { Tab } from '../common' + import { SQL_LANGUAGES } from '$lib/infer' import RawAppInputsSpecEditor from './RawAppInputsSpecEditor.svelte' import SplitPanesWrapper from '../splitPanes/SplitPanesWrapper.svelte' import SchemaForm from '../SchemaForm.svelte' @@ -80,6 +81,13 @@ } } + // A SQL argument named after a viewer property is most often a per-viewer filter, + // which only a ctx field protects. `wmill app lint` warns on the same names. + const VIEWER_IDENTITY_ARGS = ['username', 'email', 'groups'] + let isSql = $derived( + isRunnableByName(runnable) && SQL_LANGUAGES.includes(runnable.inlineScript?.language ?? '') + ) + let selectedTab = $state('test') let args = $state({}) let hubFlowPreview: OpenFlow | undefined = $state(undefined) @@ -266,6 +274,7 @@ documentationLink={meta?.['documentationLink']} allowTypeChange={meta?.['allowTypeChange']} displayType + suggestCtx={isSql && VIEWER_IDENTITY_ARGS.includes(k)} /> {/each}
diff --git a/frontend/src/lib/components/raw_apps/RawAppInputsSpecEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppInputsSpecEditor.svelte index 67ead38501..859a693ca9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInputsSpecEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInputsSpecEditor.svelte @@ -13,6 +13,7 @@ import { fieldTypeToTsType } from '../apps/utils' import type { InputType } from '../apps/inputType' import Select from '$lib/components/select/Select.svelte' + import { Alert } from '$lib/components/common' import { userStore } from '$lib/stores' import { useOperatingUser, @@ -67,6 +68,8 @@ allowTypeChange?: boolean loading?: boolean documentationLink?: string | undefined + /** Warn that this input, left as a frontend input, is open to any value a viewer sends. */ + suggestCtx?: boolean } let { @@ -85,7 +88,8 @@ displayType = false, allowTypeChange = true, loading = false, - documentationLink = undefined + documentationLink = undefined, + suggestCtx = false }: Props = $props() run(() => { @@ -193,6 +197,11 @@
{:else if componentInput?.type === 'user' || componentInput?.type == undefined} Field's value is a frontend input + {#if suggestCtx} + + If this identifies the viewer, switch it to context so the server fills it. + + {/if} {/if} {#if componentInput?.type === 'user' && ((fieldType == 'object' && format?.startsWith('resource-') && format !== 'resource-s3_object') || fieldType == 'resource')}
diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 2eb15f3c34..9e9cddeb00 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -301,7 +301,7 @@ export async function inferAnsibleExecutionMode(code: string): Promise { } } -const SQL_LANGUAGES = [ +export const SQL_LANGUAGES = [ 'postgresql', 'mysql', 'bigquery', From 05c7b688e5e963c6c313cb5da8e777cee11cd122 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:31:40 +0200 Subject: [PATCH 164/174] fix: stop session dom tools reading a raw app preview that is not current (#11700) * fix: wait for the raw app build before reading its preview dom * fix: warn the dom and screenshot tools when the raw app build failed * fix: tell the dom and screenshot tools when the raw app awaits sdk consent * fix: tell the dom and screenshot tools while the raw app sdk token loads --- .../components/raw_apps/RawAppEditor.svelte | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 46c23b79b3..ca23505ff1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -21,6 +21,7 @@ genWmillTs, normalizeRawAppRuntimeLogs, createRawAppBuildTracker, + RAW_APP_BUILD_WAIT_MS, type Runnable, type RawAppRuntimeLogEntry, type RawAppRuntimeLogRequester, @@ -488,6 +489,17 @@ addEventListener('message', function (e) { // Latest UI Builder error; cleared on next successful build. let buildError = $state(undefined) const buildTracker = createRawAppBuildTracker() + // The preview shell can be ready before the UI Builder has the files, so a fresh + // editor counts as building: reads made then would see an empty page. + buildTracker.start() + const BUILD_PENDING_TEXT = `The app is still building (or its build never started, e.g. no index entrypoint file): no build result arrived within ${RAW_APP_BUILD_WAIT_MS / 1000} seconds, so the preview shows an older build or nothing yet. Call get_app_runtime_logs to check the build, then retry.` + // A failed build never reaches the preview, which keeps the last good one, if any. + const buildFailedText = (error: string) => + `The latest build failed, so the preview does not include the latest edits (it shows the last build that succeeded, or nothing if none has). Fix the build first. Build error:\n${error}` + const SDK_CONSENT_PENDING_TEXT = + 'The preview is waiting for the user to approve the permissions the app declares in policy.frontend_sdk_scopes, so the app code is not running and the page is empty. This is not a bug in the app: do not change the code. Ask the user to approve the permissions in the preview, then retry.' + const SDK_STARTING_TEXT = + 'The preview is still loading the permissions the app declares in policy.frontend_sdk_scopes, so the app code has not started yet. Retry in a moment.' const PREVIEW_SETTLE_MS = 1000 let editorDestroyed = false // Latest uncaught runtime error thrown by the rendered app; cleared on next build. @@ -1976,6 +1988,18 @@ addEventListener('message', function (e) { // Live DOM inspection for the session chat. The element is re-read on every call, // so the model always sees the current render. const requestDomQuery: RawAppDomRequester = async (query: RawAppDomQuery) => { + await buildTracker.wait() + if (editorDestroyed) return undefined + if (buildTracker.pending) return { text: BUILD_PENDING_TEXT } + if (sdkPrompt) return { text: SDK_CONSENT_PENDING_TEXT } + if (previewSdkPending) return { text: SDK_STARTING_TEXT } + const res = await readPreviewDom(query) + return res && buildError !== undefined + ? { text: `${buildFailedText(buildError)}\n\n${res.text}` } + : res + } + + const readPreviewDom: RawAppDomRequester = async (query: RawAppDomQuery) => { const selector = query.selector?.trim() const res = await requestFromPreview('domRead', { selector }) if (!res) return undefined @@ -2142,6 +2166,12 @@ addEventListener('message', function (e) { // reconstructs the DOM into an SVG foreignObject, and a WebGL canvas is only // captured when its context was created with preserveDrawingBuffer. const captureScreenshot: RawAppScreenshotRequester = async () => { + await buildTracker.wait() + if (editorDestroyed) throw new Error('The app preview was closed') + if (buildTracker.pending) throw new Error(BUILD_PENDING_TEXT) + if (sdkPrompt) throw new Error(SDK_CONSENT_PENDING_TEXT) + if (previewSdkPending) throw new Error(SDK_STARTING_TEXT) + if (buildError !== undefined) throw new Error(buildFailedText(buildError)) const body = await requestFromPreview('elementRect', { selector: 'body' }) if (!body?.rect) throw new Error('App preview is not ready') const { width, height } = body.rect From c6692ace156f5295196af07015ad82aee6391116 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:32:10 +0200 Subject: [PATCH 165/174] fix: keep a custom error handler's slack argument on save (#11696) Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/ErrorOrRecoveryHandler.svelte | 13 +++++++----- .../lib/components/handlerSlackArg.test.ts | 21 +++++++++++++++++++ .../src/lib/components/handlerSlackArg.ts | 17 +++++++++++++++ .../(logged)/workspace_settings/+page.svelte | 17 ++++++++++----- 4 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/components/handlerSlackArg.test.ts create mode 100644 frontend/src/lib/components/handlerSlackArg.ts diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index aab564a4cf..c624b5a148 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -69,6 +69,8 @@ } from 'lucide-svelte' import { hubPaths } from '$lib/hub' import { isCloudHosted } from '$lib/cloud' + import { untrack } from 'svelte' + import { syncSlackBotArg } from './handlerSlackArg' import SmtpConfigurationStatus from './common/smtp/SmtpConfigurationStatus.svelte' import { SettingService } from '$lib/gen' import { isSmtpSettingsValid } from './instanceSettings/SmtpSettings.svelte' @@ -316,12 +318,13 @@ loadSmtpConfiguration() }) + let builtinArgs: Record | undefined $effect(() => { - if (handlerSelected === 'slack' && isSlackHandler(handlerPath)) { - handlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token' - } else { - handlerExtraArgs['slack'] = undefined - } + const isSlackBuiltin = handlerSelected === 'slack' && isSlackHandler(handlerPath) + const args = handlerExtraArgs + // A replaced args object (Discard, reload) holds the stored config, not what the built-in set. + untrack(() => syncSlackBotArg(args, isSlackBuiltin, args === builtinArgs)) + builtinArgs = isSlackBuiltin ? args : undefined }) let lastHandlerSelected: ErrorHandler | undefined = $state(undefined) diff --git a/frontend/src/lib/components/handlerSlackArg.test.ts b/frontend/src/lib/components/handlerSlackArg.test.ts new file mode 100644 index 0000000000..b087a55f26 --- /dev/null +++ b/frontend/src/lib/components/handlerSlackArg.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { syncSlackBotArg } from './handlerSlackArg' + +const AUTO = '$res:f/slack_bot/bot_token' + +describe('syncSlackBotArg', () => { + it("leaves a custom handler's slack value alone", () => { + const args = { slack: '$res:u/me/my_slack' } + syncSlackBotArg(args, false, false) + syncSlackBotArg(args, false, true) + expect(args).toEqual({ slack: '$res:u/me/my_slack' }) + }) + + it('sets the bot token for the built-in and drops it when leaving', () => { + const args: Record = {} + syncSlackBotArg(args, true, false) + expect(args.slack).toBe(AUTO) + syncSlackBotArg(args, false, true) + expect(args).toStrictEqual({}) + }) +}) diff --git a/frontend/src/lib/components/handlerSlackArg.ts b/frontend/src/lib/components/handlerSlackArg.ts new file mode 100644 index 0000000000..6b8e4d6f5c --- /dev/null +++ b/frontend/src/lib/components/handlerSlackArg.ts @@ -0,0 +1,17 @@ +import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts' + +const AUTO_SLACK_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH + +// `slack` belongs to the Slack built-in only while it holds the value the picker set; any other +// value is a custom handler's own argument and must survive handler switches and reloads. +export function syncSlackBotArg( + args: Record, + isSlackBuiltin: boolean, + wasSlackBuiltin: boolean +) { + if (isSlackBuiltin) { + args.slack = AUTO_SLACK_ARG + } else if (wasSlackBuiltin && args.slack === AUTO_SLACK_ARG) { + delete args.slack + } +} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 7228cf9f48..58f9d5d897 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -1133,10 +1133,14 @@ } // Strip keys from extraArgs that are auto-managed by child components: - // - 'slack': computed by ErrorOrRecoveryHandler's $effect based on handler type + // - 'slack' while the Slack built-in is selected: set by ErrorOrRecoveryHandler's $effect // - 'channel_name': display metadata stripped by SchemaForm's removeExtraKey() - function normalizeHandlerExtraArgs(args: Record): Record { - const { slack: _, channel_name: __, ...rest } = args + function normalizeHandlerExtraArgs( + args: Record, + handlerSelected: ErrorHandler + ): Record { + const { channel_name: _, ...rest } = args + if (handlerSelected === 'slack') delete rest.slack return rest } @@ -1146,7 +1150,10 @@ errorHandlerSelected: initialErrorHandlerSelected, errorHandlerScriptPath: initialErrorHandlerScriptPath, errorHandlerItemKind: initialErrorHandlerItemKind, - errorHandlerExtraArgs: normalizeHandlerExtraArgs(initialErrorHandlerExtraArgs), + errorHandlerExtraArgs: normalizeHandlerExtraArgs( + initialErrorHandlerExtraArgs, + initialErrorHandlerSelected + ), errorHandlerMutedOnCancel: initialErrorHandlerMutedOnCancel, errorHandlerMutedOnUserPath: initialErrorHandlerMutedOnUserPath } @@ -1155,7 +1162,7 @@ errorHandlerSelected: errorHandlerSelected, errorHandlerScriptPath: errorHandlerScriptPath, errorHandlerItemKind: errorHandlerItemKind, - errorHandlerExtraArgs: normalizeHandlerExtraArgs(errorHandlerExtraArgs), + errorHandlerExtraArgs: normalizeHandlerExtraArgs(errorHandlerExtraArgs, errorHandlerSelected), errorHandlerMutedOnCancel: errorHandlerMutedOnCancel, errorHandlerMutedOnUserPath: errorHandlerMutedOnUserPath } From 8cdb42aaae764977008f8ac4598709c7ae09af3f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 20:41:26 +0200 Subject: [PATCH 166/174] perf: run shellcheck directly from the lsp launcher (#11707) --- docker/DockerfileExtra | 2 +- docker/test_windmill_extra.ts | 55 +++++++++ lsp/pyls_launcher.py | 225 +++++++++++++++++++++++++++++++++- 3 files changed, 278 insertions(+), 4 deletions(-) diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index ace382528b..1d8d0003da 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -71,7 +71,7 @@ ENV XDG_CACHE_HOME=/pyls/.cache RUN uv pip install --system --break-system-packages pipenv tornado python-lsp-jsonrpc ruff==0.16.0 Cython # Install Node-based language servers -RUN npm install -g diagnostic-languageserver pyright +RUN npm install -g pyright # Setup LSP working directory WORKDIR /pyls diff --git a/docker/test_windmill_extra.ts b/docker/test_windmill_extra.ts index 5fd1a74108..c5884c2dd4 100644 --- a/docker/test_windmill_extra.ts +++ b/docker/test_windmill_extra.ts @@ -121,6 +121,60 @@ async function testLspWebSocket(): Promise { }) } +async function testLspShellDiagnostics(): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error('no shellcheck diagnostics within 10s')) + }, 10000) + + const ws = new WebSocket(`ws://${HOST}:${LSP_PORT}/ws/diagnostic`) + const send = (msg: object) => ws.send(JSON.stringify({ jsonrpc: '2.0', ...msg })) + + ws.onopen = () => { + // Editors still send a linter definition; the server's own configuration + // applies, so the diagnostics below have to come from shellcheck. + send({ + id: 1, + method: 'initialize', + params: { + capabilities: {}, + initializationOptions: { + linters: { shellcheck: { command: 'true', sourceName: 'shellcheck' } }, + filetypes: { shell: 'shellcheck' } + } + } + }) + send({ + method: 'textDocument/didOpen', + params: { + textDocument: { + uri: 'file:///tmp/monaco/test.sh', + languageId: 'shell', + version: 1, + text: '#!/bin/bash\necho "$unassigned"\n' + } + } + }) + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data.toString()) + if (msg.method !== 'textDocument/publishDiagnostics') return + clearTimeout(timeout) + ws.close() + const codes = msg.params.diagnostics.map((d: { code: string }) => d.code) + if (codes.includes('SC2154')) resolve() + else reject(new Error(`expected SC2154, got ${JSON.stringify(msg.params.diagnostics)}`)) + } + + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error('LSP WebSocket connection failed')) + } + }) +} + async function runLspTests(): Promise { log('\n=== LSP Service Tests (Port ' + LSP_PORT + ') ===') @@ -132,6 +186,7 @@ async function runLspTests(): Promise { let passed = true passed = (await runTest('LSP health check', testLspHealth)) && passed passed = (await runTest('LSP WebSocket connection (pyright)', testLspWebSocket)) && passed + passed = (await runTest('LSP shell diagnostics', testLspShellDiagnostics)) && passed return passed } diff --git a/lsp/pyls_launcher.py b/lsp/pyls_launcher.py index 9e522d8ce5..b3fbe7fd9a 100644 --- a/lsp/pyls_launcher.py +++ b/lsp/pyls_launcher.py @@ -1,6 +1,9 @@ +import asyncio import logging +import shutil import subprocess import threading +import time import os import urllib.request import urllib.error @@ -149,8 +152,221 @@ class LanguageServerWebSocketHandler(websocket.WebSocketHandler): class PyrightLS(LanguageServerWebSocketHandler): procargs = ["pipenv", "run", "pyright-langserver", "--stdio"] -class DiagnosticLS(LanguageServerWebSocketHandler): - procargs = ["diagnostic-languageserver", "--stdio", "--log-level", "4"] +SHELLCHECK_BIN = shutil.which("shellcheck") +# The linter is configured here rather than by the editor's initializationOptions. +SHELLCHECK_ARGS = ["--norc", "--format=json1", "-"] +SHELLCHECK_TIMEOUT_SECS = float(os.environ.get("SHELLCHECK_TIMEOUT_SECS", "10")) +SHELLCHECK_MAX_DOC_CHARS = int(os.environ.get("SHELLCHECK_MAX_DOC_CHARS", "200000")) +SHELLCHECK_MAX_DOCS_PER_CONN = 8 +# A document whose checks finish faster than this is checked on every change +# with no pause; see ShellcheckLS._changed. +SHELLCHECK_INSTANT_SECS = 0.05 +SHELLCHECK_MAX_DEBOUNCE_SECS = 1.0 +# Shared by every connection: shellcheck is CPU-bound and single-threaded, so +# more concurrent runs than cores only makes all of them slower. +_shellcheck_slots = asyncio.Semaphore(os.cpu_count() or 2) + +_SHELLCHECK_SEVERITIES = {"error": 1, "warning": 2, "info": 3, "style": 3} + + +def _utf16_col(line, col): + """shellcheck counts code points, LSP positions count UTF-16 code units.""" + col = max(col - 1, 0) + if line.isascii(): + return col + return len(line[:col].encode("utf-16-le")) // 2 + + +def shellcheck_diagnostics(text, output): + lines = text.split("\n") + + def position(line, col): + line = max(line - 1, 0) + return { + "line": line, + "character": _utf16_col(lines[line], col) if line < len(lines) else 0, + } + + diagnostics = [] + for c in json.loads(output).get("comments", []): + code = "SC%d" % c["code"] + diagnostics.append( + { + "range": { + "start": position(c["line"], c["column"]), + "end": position(c["endLine"], c["endColumn"]), + }, + "severity": _SHELLCHECK_SEVERITIES.get(c["level"], 3), + "code": code, + "codeDescription": {"href": "https://www.shellcheck.net/wiki/" + code}, + "source": "shellcheck", + "message": c["message"], + } + ) + return diagnostics + + +class ShellcheckLS(websocket.WebSocketHandler): + """Lints shell documents by running shellcheck itself, speaking only the + slice of LSP that takes: document sync in, diagnostics out.""" + + def open(self): + self.docs = {} + self.tasks = {} + self.cost = {} + self.linted = {} + + def check_origin(self, origin): + return True + + def on_message(self, message): + try: + msg = json.loads(message) + method = msg.get("method") + params = msg.get("params") or {} + if method == "initialize": + self._send( + { + "jsonrpc": "2.0", + "id": msg.get("id"), + "result": { + "capabilities": { + "textDocumentSync": {"openClose": True, "change": 1} + }, + "serverInfo": {"name": "windmill-shellcheck"}, + }, + } + ) + elif method == "textDocument/didOpen": + doc = params["textDocument"] + self._changed(doc["uri"], doc.get("version"), doc["text"]) + elif method == "textDocument/didChange": + doc = params["textDocument"] + self._changed( + doc["uri"], doc.get("version"), params["contentChanges"][-1]["text"] + ) + elif method == "textDocument/didClose": + self._forget(params["textDocument"]["uri"]) + elif method == "shutdown": + self._send({"jsonrpc": "2.0", "id": msg.get("id"), "result": None}) + elif method is not None and "id" in msg: + self._send( + { + "jsonrpc": "2.0", + "id": msg["id"], + "error": {"code": -32601, "message": "Method not found"}, + } + ) + except (ValueError, KeyError, IndexError, TypeError, AttributeError): + log.warning("shellcheck: dropping malformed message") + + def on_close(self): + for uri in list(self.docs): + self._forget(uri) + + def _send(self, msg): + try: + self.write_message(json.dumps(msg)) + except websocket.WebSocketClosedError: + pass + + def _forget(self, uri): + task = self.tasks.pop(uri, None) + if task: + task.cancel() + self.docs.pop(uri, None) + self.cost.pop(uri, None) + self.linted.pop(uri, None) + + def _changed(self, uri, version, text): + if not isinstance(uri, str) or not isinstance(text, str): + raise TypeError + if uri not in self.docs and len(self.docs) >= SHELLCHECK_MAX_DOCS_PER_CONN: + return + if len(text) > SHELLCHECK_MAX_DOC_CHARS: + # Not checked, so not kept either: reported as having no findings. + text = None + self.docs[uri] = (version, text) + if uri in self.linted and self.linted[uri] == text: + return + running = self.tasks.pop(uri, None) + # The pause before a check is what that document's checks cost: one that + # is cheap is checked at once, one that is not waits out a typing burst + # rather than burning a core on results the next keystroke discards. + delay = self.cost.get(uri, 0) + if delay < SHELLCHECK_INSTANT_SECS: + delay = 0 + if running and not running.done(): + running.cancel() + delay = max(delay, SHELLCHECK_INSTANT_SECS) + self.tasks[uri] = asyncio.ensure_future(self._lint(uri, version, text, delay)) + + async def _lint(self, uri, version, text, delay): + if delay: + await asyncio.sleep(delay) + diagnostics = [] + if SHELLCHECK_BIN and text is not None: + async with _shellcheck_slots: + started = time.monotonic() + try: + output = await self._run(text) + self.cost[uri] = 0 + finally: + # A killed check counts too, as a lower bound: it is the only + # measure a document edited faster than it can be checked gets. + if uri in self.docs: + self.cost[uri] = min( + max(time.monotonic() - started, self.cost.get(uri, 0)), + SHELLCHECK_MAX_DEBOUNCE_SECS, + ) + if output is None: + return + try: + diagnostics = shellcheck_diagnostics(text, output) + except (ValueError, KeyError, TypeError) as e: + log.warning("shellcheck: unreadable output: %s", e) + return + if self.docs.get(uri) != (version, text): + return + self.linted[uri] = text + params = {"uri": uri, "diagnostics": diagnostics} + if version is not None: + params["version"] = version + self._send( + { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": params, + } + ) + + async def _run(self, text): + try: + proc = await asyncio.create_subprocess_exec( + SHELLCHECK_BIN, + *SHELLCHECK_ARGS, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except OSError as e: + log.warning("shellcheck: could not start: %s", e) + return None + try: + output, _ = await asyncio.wait_for( + proc.communicate(text.encode("utf-8", "replace")), + SHELLCHECK_TIMEOUT_SECS, + ) + return output + except asyncio.TimeoutError: + log.warning("shellcheck: timed out on a %d char document", len(text)) + return None + finally: + # Also reached when a newer change cancels this check mid-run. + if proc.returncode is None: + proc.kill() + await proc.wait() + class RuffLS(LanguageServerWebSocketHandler): procargs = ["ruff", "server"] @@ -201,11 +417,14 @@ if __name__ == "__main__": # spawned `ruff server` picks it up. start_ruff_config_poller() + if not SHELLCHECK_BIN: + log.warning("shellcheck is not on PATH: shell scripts will not be linted") + port = int(os.environ.get("PORT", "3001")) app = web.Application( [ (r"/ws/pyright", PyrightLS), - (r"/ws/diagnostic", DiagnosticLS), + (r"/ws/diagnostic", ShellcheckLS), (r"/ws/ruff", RuffLS), (r"/ws/deno", DenoLS), (r"/ws/go", GoLS), From 4be67fe3ac09f722f31ef85e6fc757bb0a2fabe4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 20:57:36 +0200 Subject: [PATCH 167/174] fix(groups): recompute member roles when an instance group role is cleared (#11708) * fix(groups): recompute member roles when an instance group role is cleared Co-Authored-By: Claude Opus 5.5 * fix: restore sqlx cache entry still used by the scim group delete Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-api-groups/src/groups.rs | 43 +++++------- .../tests/groups.rs | 66 +++++++++++++++++++ 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index 0209af8585..d9550d2f5b 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -470,26 +470,16 @@ async fn update_igroup( require_super_admin(&db, &authed).await?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; - let exists_opt = sqlx::query("SELECT 1 FROM instance_group WHERE name = $1") - .bind(name.clone()) - .fetch_optional(&mut *tx) - .await?; - not_found_if_none(exists_opt, "instance_group", name.clone())?; + let old_role_opt = sqlx::query_scalar!( + "SELECT instance_role FROM instance_group WHERE name = $1 FOR UPDATE", + &name + ) + .fetch_optional(&mut *tx) + .await?; + let old_role = not_found_if_none(old_role_opt, "instance_group", name.clone())?; let validated_role = validate_instance_role(&igroup_update.instance_role)?; - // Fetch old role before updating so we can detect changes - let old_role = if igroup_update.instance_role.is_some() { - sqlx::query_scalar!( - "SELECT instance_role FROM instance_group WHERE name = $1", - &name - ) - .fetch_one(&mut *tx) - .await? - } else { - None - }; - sqlx::query("UPDATE instance_group SET summary = $1, instance_role = $2 WHERE name = $3") .bind(igroup_update.new_summary) .bind(&validated_role) @@ -497,8 +487,9 @@ async fn update_igroup( .execute(&mut *tx) .await?; - // If instance_role actually changed, propagate to all group members - if igroup_update.instance_role.is_some() && old_role != validated_role { + // An omitted or null instance_role is stored as NULL above, so it revokes like any other + // change: members must be recomputed whenever the stored role differs. + if old_role != validated_role { propagate_instance_group_roles(&name, &mut tx).await?; } @@ -664,21 +655,19 @@ async fn delete_igroup( // FOR UPDATE: the group row is the group-level mutex, taken before the workspace // advisory locks (see reconcile_workspace_instance_groups). - let group_role = sqlx::query_scalar!( + sqlx::query_scalar!( "SELECT instance_role FROM instance_group WHERE name = $1 FOR UPDATE", &name ) .fetch_optional(&mut *tx) - .await? - .flatten(); + .await?; - let affected_members: Vec = if group_role.is_some() { + // Collected whatever the group's current role: a member can still hold a role this group + // granted before it was cleared. + let affected_members: Vec = sqlx::query_scalar!("SELECT email FROM email_to_igroup WHERE igroup = $1", &name) .fetch_all(&mut *tx) - .await? - } else { - vec![] - }; + .await?; // Captured and advisory-locked before the settings update strips the group from them. #[cfg(feature = "private")] diff --git a/backend/windmill-api-integration-tests/tests/groups.rs b/backend/windmill-api-integration-tests/tests/groups.rs index 26e5caeea1..9928d1c060 100644 --- a/backend/windmill-api-integration-tests/tests/groups.rs +++ b/backend/windmill-api-integration-tests/tests/groups.rs @@ -497,6 +497,72 @@ async fn test_remove_user_from_instance_group_rederives_role( Ok(()) } +/// Clearing an instance group's role, or deleting a group whose role was cleared out from under +/// its members, must take the instance-level role back from them. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_instance_group_role_revoked_on_clear_and_delete( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let global_base = format!("http://localhost:{port}/api/groups"); + let email = "test2@windmill.dev"; + + let is_super_admin = || async { + sqlx::query_scalar::<_, bool>("SELECT super_admin FROM password WHERE email = $1") + .bind(email) + .fetch_one(&db) + .await + }; + + let resp = authed(client().post(format!("{global_base}/create"))) + .json(&json!({ "name": "admins_grp" })) + .send() + .await?; + assert_eq!(resp.status(), 200, "create: {}", resp.text().await?); + let resp = authed(client().post(format!("{global_base}/update/admins_grp"))) + .json(&json!({ "new_summary": "", "instance_role": "superadmin" })) + .send() + .await?; + assert_eq!(resp.status(), 200, "set role: {}", resp.text().await?); + let resp = authed(client().post(format!("{global_base}/adduser/admins_grp"))) + .json(&json!({ "email": email })) + .send() + .await?; + assert_eq!(resp.status(), 200, "adduser: {}", resp.text().await?); + assert!(is_super_admin().await?, "group should grant superadmin"); + + let resp = authed(client().post(format!("{global_base}/update/admins_grp"))) + .json(&json!({ "new_summary": "", "instance_role": null })) + .send() + .await?; + assert_eq!(resp.status(), 200, "update: {}", resp.text().await?); + assert!( + !is_super_admin().await?, + "clearing the group's role must revoke superadmin" + ); + + // A member left elevated while the group's stored role is already NULL. + sqlx::query( + "UPDATE password SET super_admin = true, role_source = 'instance_group' WHERE email = $1", + ) + .bind(email) + .execute(&db) + .await?; + + let resp = authed(client().delete(format!("{global_base}/delete/admins_grp"))) + .send() + .await?; + assert_eq!(resp.status(), 200, "delete: {}", resp.text().await?); + assert!( + !is_super_admin().await?, + "deleting a role-cleared group must revoke superadmin" + ); + + Ok(()) +} + /// An overwrite import that moves a member from a dropped group to a retained one must keep /// their workspace data. /// From 4942149b05c9384b39402f0e3cb7771565888f45 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Tue, 13 Oct 2026 02:27:13 -0400 Subject: [PATCH 168/174] feat: encrypt workspace secrets with AES-256-GCM (#11678) * feat: encrypt workspace secrets with AES-256-GCM Co-Authored-By: Claude Opus 5.5 (1M context) * docs: note the aead gate covers workers only and is one-way Co-Authored-By: Claude Opus 5.5 (1M context) * fix: skip secrets the upgrade cannot store and clear its guard on panic Co-Authored-By: Claude Opus 5.5 (1M context) * fix: start aead secret writes one release after read support Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to 06610ecc950ed64a2709d7f1aa8c7b49a95fdbae This commit updates the EE repository reference after PR #869 was merged in windmill-ee-private. Previous ee-repo-ref: 599eae0034b34c5e0d3d42df37cf18bbe01f9b49 New ee-repo-ref: 06610ecc950ed64a2709d7f1aa8c7b49a95fdbae Automated by sync-ee-ref workflow. * feat: widen variable.value to fit AES-GCM ciphertext of the longest legacy secret Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse legacy secrets longer than 15000 chars so every secret can be upgraded Co-Authored-By: Claude Opus 5.5 (1M context) * fix: cap legacy secrets at 15000 chars with a check constraint so every writer is covered Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + ...261012213357_widen_variable_value.down.sql | 12 + ...20261012213357_widen_variable_value.up.sql | 8 + backend/src/monitor.rs | 19 ++ .../src/remote_deploy.rs | 6 +- .../windmill-api-workspaces/src/workspaces.rs | 4 +- backend/windmill-api/src/ai_sessions.rs | 6 +- backend/windmill-common/Cargo.toml | 5 +- backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/min_version.rs | 5 + backend/windmill-common/src/utils.rs | 3 + backend/windmill-common/src/variables.rs | 191 +++++++++++-- .../windmill-common/src/workspace_crypt.rs | 255 ++++++++++++++++++ backend/windmill-store/src/variables.rs | 3 +- cli/src/commands/variable/variable.ts | 3 +- 16 files changed, 496 insertions(+), 27 deletions(-) create mode 100644 backend/migrations/20261012213357_widen_variable_value.down.sql create mode 100644 backend/migrations/20261012213357_widen_variable_value.up.sql create mode 100644 backend/windmill-common/src/workspace_crypt.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 610768134f..1bd38513a7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16333,6 +16333,7 @@ dependencies = [ "git-version", "globset", "hex", + "hkdf", "hmac 0.12.1", "hyper 1.12.0", "indexmap 2.14.2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6f2420b8a3..aaf5a2e8df 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -599,6 +599,7 @@ p256 = "0.13" spki = { version = "0.7", features = ["pem"] } pkcs1 = "0.7" aes-gcm = "0.10.3" +hkdf = "0.12.4" async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] } once_cell = "1.17.1" dashmap = "6.1.0" diff --git a/backend/migrations/20261012213357_widen_variable_value.down.sql b/backend/migrations/20261012213357_widen_variable_value.down.sql new file mode 100644 index 0000000000..48b61c3f65 --- /dev/null +++ b/backend/migrations/20261012213357_widen_variable_value.down.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + too_long BIGINT; +BEGIN + SELECT count(*) INTO too_long FROM variable WHERE length(value) > 15000; + IF too_long > 0 THEN + RAISE EXCEPTION 'cannot narrow variable.value to VARCHAR(15000): % row(s) are longer than 15000 characters', too_long; + END IF; +END $$; + +ALTER TABLE variable DROP CONSTRAINT IF EXISTS variable_value_length; +ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR(15000); diff --git a/backend/migrations/20261012213357_widen_variable_value.up.sql b/backend/migrations/20261012213357_widen_variable_value.up.sql new file mode 100644 index 0000000000..06dc8a191a --- /dev/null +++ b/backend/migrations/20261012213357_widen_variable_value.up.sql @@ -0,0 +1,8 @@ +-- AES-256-GCM ciphertext is 32 bytes (magic, nonce, tag) longer than legacy CBC before base64, +-- so the longest CBC value that fits 15000 chars is exactly 15040 chars once re-encrypted. +ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR(15040); + +-- Only GCM secrets (base64 of the `wmA2` magic starts `d21BM`) may use the extra room: a longer +-- CBC value could never be re-encrypted to fit, and would fail every rotation of its workspace key. +ALTER TABLE variable ADD CONSTRAINT variable_value_length + CHECK (length(value) <= 15000 OR (is_secret AND value LIKE 'd21BM%')); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 8de85baa24..24f9d8221c 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -4449,6 +4449,24 @@ pub async fn monitor_db( } }; + // Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM. + // Every ~5 min until one pass has gone through every workspace; spawned so a large instance + // does not hold up the join below. + let legacy_secret_upgrade_f = async { + if server_mode + && iteration.is_some() + && iteration.as_ref().unwrap().should_run(30) + && windmill_common::variables::legacy_secret_upgrade_pending() + { + if let Some(db) = conn.as_sql() { + let db = db.clone(); + tokio::spawn(async move { + windmill_common::variables::upgrade_legacy_secret_variables(&db).await + }); + } + } + }; + // Delete the AI session backups older than their workspace's retention. Every ~40 min // (240 iterations at the default 10 s, the most a u8 `should_run` counts): the retention // counts in days. Spawned for the same reason as the credential maintenance above, a @@ -4521,6 +4539,7 @@ pub async fn monitor_db( git_auto_pull_f, git_credential_maintenance_f, ai_session_retention_f, + legacy_secret_upgrade_f, pipeline_freshness_watchdog_f, reconcile_unarmed_schedules_f, stranded_jobs_f, diff --git a/backend/windmill-api-workspaces/src/remote_deploy.rs b/backend/windmill-api-workspaces/src/remote_deploy.rs index 3e701cbf14..a0f922a607 100644 --- a/backend/windmill-api-workspaces/src/remote_deploy.rs +++ b/backend/windmill-api-workspaces/src/remote_deploy.rs @@ -25,11 +25,11 @@ use axum::{ }; use chrono::{DateTime, Utc}; use lazy_static::lazy_static; -use magic_crypt::MagicCrypt256; use regex::Regex; use serde::{Deserialize, Serialize}; use windmill_api_auth::{is_effectively_unscoped, ApiAuthed, Tokened}; use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::variables::WorkspaceCrypt; use windmill_common::{ auth::hash_token, error::{error_source_chain, Error, Result}, @@ -676,8 +676,8 @@ async fn proxy( pub(crate) async fn reencrypt_tokens( conn: &mut sqlx::PgConnection, w_id: &str, - old: &MagicCrypt256, - new: &MagicCrypt256, + old: &WorkspaceCrypt, + new: &WorkspaceCrypt, ) -> Result<()> { let rows = sqlx::query!( "SELECT email, token FROM remote_deploy_token diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 7f6eace660..3bfcef5c96 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6317,8 +6317,8 @@ async fn set_encryption_key( async fn reencrypt_git_sync_secrets( conn: &mut sqlx::PgConnection, w_id: &str, - old: &magic_crypt::MagicCrypt256, - new: &magic_crypt::MagicCrypt256, + old: &windmill_common::variables::WorkspaceCrypt, + new: &windmill_common::variables::WorkspaceCrypt, ) -> Result<()> { let Some((mut credentials, mut git_sync)) = sqlx::query_as::<_, (serde_json::Value, Option)>( diff --git a/backend/windmill-api/src/ai_sessions.rs b/backend/windmill-api/src/ai_sessions.rs index 65a8137c4e..9e980a54f4 100644 --- a/backend/windmill-api/src/ai_sessions.rs +++ b/backend/windmill-api/src/ai_sessions.rs @@ -24,7 +24,7 @@ use axum::{ Extension, Json, Router, }; use futures::{StreamExt, TryStreamExt}; -use magic_crypt::{MagicCrypt256, MagicCryptTrait}; +use magic_crypt::MagicCryptTrait; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::sync::Arc; @@ -35,7 +35,7 @@ use windmill_api_workspaces::ai_session_backups::{ use windmill_api_workspaces::workspaces::sessions_retention_days; use windmill_common::error::{Error, JsonResult, Result}; use windmill_common::utils::calculate_hash; -use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key}; +use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key, WorkspaceCrypt}; use windmill_object_store::object_store_reexports::{ ObjectStore, ObjectStoreError, Path as ObjectPath, PutPayload, }; @@ -135,7 +135,7 @@ enum Read { /// The user's prefix in the workspace storage, plus what reads and writes it. struct Backend { store: Arc, - mc: MagicCrypt256, + mc: WorkspaceCrypt, prefix: String, /// Name the storage and the generation the objects are under, for the browser's sync /// state: a row recorded against another storage or generation is stale, a removal is diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 97dbc7e7bf..e447baf563 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -8,7 +8,7 @@ edition.workspace = true default = [] enterprise = ["dep:aws-config"] instance_config_schema = ["dep:schemars"] -local_reports = ["dep:rsa", "dep:aes-gcm"] +local_reports = ["dep:rsa"] private = ["dep:aws-sdk-rds", "dep:aws-sdk-secretsmanager", "dep:aws-config"] jemalloc = ["dep:tikv-jemalloc-ctl"] tantivy = [] @@ -119,7 +119,8 @@ rsa = { workspace = true, optional = true } p256 = { workspace = true, optional = true } spki = { workspace = true } pkcs1 = { workspace = true } -aes-gcm = { workspace = true, optional = true } +aes-gcm.workspace = true +hkdf.workspace = true semver.workspace = true croner.workspace = true diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index f15b43182f..58a477d7b9 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -157,6 +157,7 @@ pub mod wac; pub mod webhook; pub mod worker; pub mod worker_group_job_stats; +pub mod workspace_crypt; pub mod workspaces; pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50; diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index 930b718b7e..05b664b23d 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -23,6 +23,11 @@ pub const MIN_VERSION_SUPPORTS_BUN_LOCKFILE_V2: VC = vc(1, 794, 0, "Bun v2 lockf // them, so a restriction set while one is live is silently ignored on the jobs it runs. Must // name the release this ships in. pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted job tokens"); +// Gates writing AES-256-GCM workspace ciphertext and the background pass that rewrites the +// stored secret variables. Reading it ships one release earlier (1.831), so a rollback to +// that release still reads every secret; below it, secrets written after the switch stay +// unreadable until rolled forward. Only worker versions are seen here, not servers. +pub const MIN_VERSION_SUPPORTS_AEAD_SECRETS: VC = vc(1, 832, 0, "AES-GCM secret encryption"); pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing"); pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing"); diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index af6bdfe2b3..116a15303e 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -281,6 +281,9 @@ pub fn sanitize_db_error(e: sqlx::Error) -> Error { return Error::from(e); }; match db_err.code().as_deref() { + Some("23514") if db_err.constraint() == Some("variable_value_length") => { + Error::BadRequest("A field exceeds its maximum length".to_string()) + } // check_violation — `proper_id` / `proper_name` and friends Some("23514") => Error::BadRequest( "Invalid path or name: it does not match the required format".to_string(), diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index f45d457093..58766cf25b 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -12,11 +12,13 @@ use crate::scripts::ScriptHash; use crate::secret_backend::{get_secret_value, is_external_stored_value}; use crate::utils::WarnAfterExt; use crate::worker::Connection; +use crate::workspace_crypt::CipherFormat; +pub use crate::workspace_crypt::WorkspaceCrypt; use crate::{worker::WORKER_GROUP, BASE_URL, DB}; use chrono::{SecondsFormat, Utc}; -use magic_crypt::{MagicCrypt256, MagicCryptTrait}; use quick_cache::sync::Cache; use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; lazy_static::lazy_static! { pub static ref SECRET_SALT: Option = std::env::var("SECRET_SALT").ok(); @@ -211,7 +213,7 @@ pub struct CreateVariable { pub ws_specific: Option, } -pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result { +pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result { // Check cache first (300-second staleness) let cached_key_o = WORKSPACE_CRYPT_CACHE.get(w_id).and_then(|(ts, key)| { if ts > chrono::Utc::now().timestamp() - 300 { @@ -235,7 +237,7 @@ pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result crate::error::Result { +) -> crate::error::Result { let key = get_workspace_key(w_id, db).await?; Ok(crypt_from_key_with_suffix(&key, key_suffix)) } /// The cipher `build_crypt_with_key_suffix` builds, from a key string in hand. -pub fn crypt_from_key_with_suffix(key: &str, key_suffix: &str) -> MagicCrypt256 { +pub fn crypt_from_key_with_suffix(key: &str, key_suffix: &str) -> WorkspaceCrypt { let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() { format!("{}{}{}", key, salt, key_suffix) } else { format!("{}{}", key, key_suffix) }; - magic_crypt::new_magic_crypt!(crypt_key, 256) + WorkspaceCrypt::new(&crypt_key) } pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result { @@ -371,27 +373,178 @@ pub async fn get_secret_value_as_admin( Ok(r) } -pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String { - mc.encrypt_str_to_base64(value) +pub fn encrypt(mc: &WorkspaceCrypt, value: &str) -> String { + mc.encrypt_str(value) } const DECRYPT_FAILED: &str = "Could not decrypt value. The value may have been encrypted with a different key."; /// Every failure returns the same message so responses carry no signal about why decryption -/// failed (padding, base64, UTF-8); the cause is only logged. -pub fn decrypt(mc: &MagicCrypt256, value: String) -> error::Result { - mc.decrypt_base64_to_string(value).map_err(|e| { +/// failed (authentication, padding, base64, UTF-8); the kind of failure is only logged. +pub fn decrypt(mc: &WorkspaceCrypt, value: String) -> error::Result { + mc.decrypt_str(&value).map_err(|e| { tracing::warn!("decryption failed: {e}"); error::Error::internal_err(DECRYPT_FAILED.to_string()) }) } +static LEGACY_SECRET_UPGRADE_DONE: AtomicBool = AtomicBool::new(false); +static LEGACY_SECRET_UPGRADE_RUNNING: AtomicBool = AtomicBool::new(false); +const LEGACY_SECRET_UPGRADE_BATCH: i64 = 200; + +/// Whether this process still has to run [`upgrade_legacy_secret_variables`]: once new values +/// are written in v2, until one pass has gone through every workspace. +pub fn legacy_secret_upgrade_pending() -> bool { + CipherFormat::current() == CipherFormat::V2 + && !LEGACY_SECRET_UPGRADE_DONE.load(Ordering::Relaxed) +} + +/// Re-encrypt the secret variables still in the legacy format in v2, workspace by workspace. +/// Idempotent, and emits no git-sync event: the plaintexts do not change. +pub async fn upgrade_legacy_secret_variables(db: &DB) { + struct Running; + impl Drop for Running { + fn drop(&mut self) { + LEGACY_SECRET_UPGRADE_RUNNING.store(false, Ordering::Release); + } + } + if !legacy_secret_upgrade_pending() + || LEGACY_SECRET_UPGRADE_RUNNING.swap(true, Ordering::AcqRel) + { + return; + } + // Cleared on drop, so a pass that panics does not stop every later one in this process. + let _running = Running; + upgrade_legacy_secret_variables_pass(db).await; +} + +async fn upgrade_legacy_secret_variables_pass(db: &DB) { + let workspaces: Vec = match sqlx::query_scalar("SELECT id FROM workspace ORDER BY id") + .fetch_all(db) + .await + { + Ok(w) => w, + Err(e) => { + tracing::error!("secret encryption upgrade: listing workspaces failed: {e:#}"); + return; + } + }; + tracing::info!( + "secret encryption upgrade: re-encrypting legacy secret variables of {} workspaces in AES-256-GCM", + workspaces.len() + ); + let (mut upgraded, mut skipped, mut failed_workspaces) = (0, 0, 0); + for w_id in workspaces { + match upgrade_workspace_secret_variables(db, &w_id).await { + Ok((u, s)) => { + if u > 0 || s > 0 { + tracing::info!( + "secret encryption upgrade: workspace {w_id}: {u} re-encrypted, {s} skipped" + ); + } + upgraded += u; + skipped += s; + } + Err(e) => { + failed_workspaces += 1; + tracing::error!("secret encryption upgrade: workspace {w_id} failed: {e:#}"); + } + } + } + tracing::info!( + "secret encryption upgrade: done, {upgraded} re-encrypted, {skipped} skipped, {failed_workspaces} workspaces failed" + ); + if failed_workspaces == 0 { + LEGACY_SECRET_UPGRADE_DONE.store(true, Ordering::Relaxed); + } +} + +/// The number of values re-encrypted and of values skipped as they do not decrypt. +async fn upgrade_workspace_secret_variables(db: &DB, w_id: &str) -> error::Result<(usize, usize)> { + let (mut upgraded, mut skipped) = (0, 0); + let mut after = String::new(); + loop { + let mut tx = db.begin().await?; + // Shared, so a rotation (which locks the key row for update and rewrites every secret) + // never interleaves with a batch, and the key read here is the one the values are under. + let key: Option = sqlx::query_scalar( + "SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud' FOR SHARE", + ) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + let Some(key) = key else { + return Ok((upgraded, skipped)); + }; + let mc = crypt_from_key_with_suffix(&key, ""); + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT path, value FROM variable WHERE workspace_id = $1 AND is_secret AND path > $2 \ + ORDER BY path LIMIT $3", + ) + .bind(w_id) + .bind(&after) + .bind(LEGACY_SECRET_UPGRADE_BATCH) + .fetch_all(&mut *tx) + .await?; + let last_page = (rows.len() as i64) < LEGACY_SECRET_UPGRADE_BATCH; + for (path, value) in rows { + if value.is_empty() || is_external_stored_value(&value) || mc.is_v2(&value) { + after = path; + continue; + } + match mc.upgrade(&value) { + Some(new_value) => { + // In a savepoint, so a value the update rejects (v2 is up to 44 chars longer + // than CBC, which a column narrower than 15040 cannot hold) is skipped + // without losing the batch. + let mut sp = sqlx::Acquire::begin(&mut tx).await?; + // Only over the value it was computed from, so a write racing it wins. + match sqlx::query( + "UPDATE variable SET value = $1 \ + WHERE workspace_id = $2 AND path = $3 AND value = $4", + ) + .bind(new_value) + .bind(w_id) + .bind(&path) + .bind(&value) + .execute(&mut *sp) + .await + { + Ok(r) => { + sp.commit().await?; + upgraded += r.rows_affected() as usize; + } + Err(e) => { + sp.rollback().await?; + tracing::warn!( + "secret encryption upgrade: secret variable {path} of workspace {w_id} could not be stored re-encrypted, leaving it as is: {e}" + ); + skipped += 1; + } + } + } + None => { + tracing::warn!( + "secret encryption upgrade: secret variable {path} of workspace {w_id} does not decrypt, leaving it as is" + ); + skipped += 1; + } + } + after = path; + } + tx.commit().await?; + if last_page { + return Ok((upgraded, skipped)); + } + } +} + pub const WM_SCHEDULED_FOR: &str = "WM_SCHEDULED_FOR"; lazy_static::lazy_static! { pub static ref CUSTOM_ENVS_CACHE: Cache)> = Cache::new(100); - pub static ref WORKSPACE_CRYPT_CACHE: Cache = Cache::new(1000); + pub static ref WORKSPACE_CRYPT_CACHE: Cache = Cache::new(1000); } @@ -753,16 +906,24 @@ mod tests { DECRYPT_FAILED, }; use crate::error::Error; + use crate::workspace_crypt::WorkspaceCrypt; + use base64::Engine; use magic_crypt::MagicCryptTrait; #[test] fn decrypt_failures_all_return_the_same_message() { - let mc = magic_crypt::new_magic_crypt!("workspace-key", 256); - let other = magic_crypt::new_magic_crypt!("another-key", 256); + let mc = WorkspaceCrypt::new("workspace-key"); + let legacy = magic_crypt::new_magic_crypt!("workspace-key", 256); + let other = WorkspaceCrypt::new("another-key"); + let b64 = base64::engine::general_purpose::STANDARD; + let v2 = mc.upgrade(&legacy.encrypt_str_to_base64("secret")).unwrap(); + let mut tampered = b64.decode(v2).unwrap(); + *tampered.last_mut().unwrap() ^= 1; let failures = [ "not base64 at all!".to_string(), - mc.encrypt_bytes_to_base64(&[0xff, 0xfe, 0xfd]), + legacy.encrypt_bytes_to_base64(&[0xff, 0xfe, 0xfd]), other.encrypt_str_to_base64("secret"), + b64.encode(&tampered), ]; for value in failures { match decrypt(&mc, value.clone()) { diff --git a/backend/windmill-common/src/workspace_crypt.rs b/backend/windmill-common/src/workspace_crypt.rs new file mode 100644 index 0000000000..a561060e32 --- /dev/null +++ b/backend/windmill-common/src/workspace_crypt.rs @@ -0,0 +1,255 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! The cipher behind `variables::encrypt`/`decrypt`. +//! +//! Values are written as AES-256-GCM ("v2"), standard base64 of +//! `MAGIC (4) || nonce (12) || AES-256-GCM(PKCS#7(plaintext)) || tag (16)`. +//! Values written before are AES-256-CBC (`magic_crypt`) and are still read. +//! +//! The plaintext is padded to the block size so a v2 value decodes to a multiple of 16 bytes, +//! like a CBC one: the CLI and `validate_already_encrypted_secret` tell ciphertext from +//! plaintext by exactly that, and an old CLI that took a v2 value for plaintext would push it +//! to be encrypted again, corrupting the secret. + +use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::Engine; +use hkdf::Hkdf; +use magic_crypt::{MagicCrypt256, MagicCryptTrait}; +use sha2::Sha256; + +use crate::min_version::MIN_VERSION_SUPPORTS_AEAD_SECRETS; + +pub const V2_MAGIC: [u8; 4] = *b"wmA2"; +const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; +const BLOCK: usize = 16; + +const AEAD_KEY_INFO: &[u8] = b"windmill workspace secret aes-256-gcm v1"; + +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +/// Why a value did not decrypt. Only ever logged, never returned to a caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecryptFailure { + Encoding, + Authentication, + Utf8, +} + +impl std::fmt::Display for DecryptFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + DecryptFailure::Encoding => "not base64", + DecryptFailure::Authentication => "authentication or padding check failed", + DecryptFailure::Utf8 => "plaintext is not UTF-8", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CipherFormat { + LegacyCbc, + V2, +} + +impl CipherFormat { + /// What new values are written as. Old workers read secrets straight from the database with + /// the CBC cipher alone, so v2 is written only once every live worker reads it. + pub fn current() -> Self { + if MIN_VERSION_SUPPORTS_AEAD_SECRETS.met_conservatively() { + CipherFormat::V2 + } else { + CipherFormat::LegacyCbc + } + } +} + +#[derive(Clone)] +pub struct WorkspaceCrypt { + legacy: MagicCrypt256, + aead: Aes256Gcm, +} + +/// Code that drives `magic_crypt` directly on this cipher (app link secrets, AI session +/// objects) keeps the legacy format it always had. +impl std::ops::Deref for WorkspaceCrypt { + type Target = MagicCrypt256; + fn deref(&self) -> &MagicCrypt256 { + &self.legacy + } +} + +impl WorkspaceCrypt { + /// `crypt_key` is the key material the CBC cipher was always built from (workspace key, salt, + /// suffix). The AEAD key is derived from it under its own label, so the two ciphers never + /// share a key. + pub fn new(crypt_key: &str) -> Self { + let mut aead_key = [0u8; 32]; + Hkdf::::new(None, crypt_key.as_bytes()) + .expand(AEAD_KEY_INFO, &mut aead_key) + .expect("32 bytes is a valid HKDF-SHA256 output length"); + Self { + legacy: magic_crypt::new_magic_crypt!(crypt_key, 256), + aead: Aes256Gcm::new(Key::::from_slice(&aead_key)), + } + } + + pub fn encrypt_str(&self, plaintext: &str) -> String { + self.encrypt_str_in(plaintext, CipherFormat::current()) + } + + pub fn encrypt_str_in(&self, plaintext: &str, format: CipherFormat) -> String { + match format { + CipherFormat::V2 => B64.encode(self.seal(plaintext.as_bytes())), + CipherFormat::LegacyCbc => self.legacy.encrypt_str_to_base64(plaintext), + } + } + + pub fn decrypt_str(&self, ciphertext: &str) -> Result { + let bytes = B64 + .decode(ciphertext) + .map_err(|_| DecryptFailure::Encoding)?; + String::from_utf8(self.decrypt_bytes(&bytes)?).map_err(|_| DecryptFailure::Utf8) + } + + /// Whether `ciphertext` is a v2 value under this key. + pub fn is_v2(&self, ciphertext: &str) -> bool { + B64.decode(ciphertext) + .is_ok_and(|bytes| self.open(&bytes).is_some()) + } + + /// The v2 form of a legacy `ciphertext`, checked to decrypt back to the same plaintext. + /// `None` for a v2 value and for anything that does not decrypt under this key. + pub fn upgrade(&self, ciphertext: &str) -> Option { + if self.is_v2(ciphertext) { + return None; + } + let plaintext = self.decrypt_str(ciphertext).ok()?; + let upgraded = self.encrypt_str_in(&plaintext, CipherFormat::V2); + (self.decrypt_str(&upgraded).ok()? == plaintext).then_some(upgraded) + } + + fn decrypt_bytes(&self, bytes: &[u8]) -> Result, DecryptFailure> { + match self.open(bytes) { + Some(plaintext) => Ok(plaintext), + // Also for a value with the magic: a CBC ciphertext starts with it one time in 2^32. + None => self + .legacy + .decrypt_bytes_to_bytes(bytes) + .map_err(|_| DecryptFailure::Authentication), + } + } + + fn seal(&self, plaintext: &[u8]) -> Vec { + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let pad = BLOCK - plaintext.len() % BLOCK; + let mut padded = Vec::with_capacity(plaintext.len() + pad); + padded.extend_from_slice(plaintext); + padded.resize(plaintext.len() + pad, pad as u8); + let sealed = self + .aead + .encrypt(&nonce, padded.as_slice()) + .expect("AES-GCM encryption fails only past 64 GiB of plaintext"); + let mut out = Vec::with_capacity(V2_MAGIC.len() + NONCE_LEN + sealed.len()); + out.extend_from_slice(&V2_MAGIC); + out.extend_from_slice(&nonce); + out.extend_from_slice(&sealed); + out + } + + fn open(&self, data: &[u8]) -> Option> { + let body = data.strip_prefix(&V2_MAGIC)?; + if body.len() < NONCE_LEN + BLOCK + TAG_LEN || (body.len() - NONCE_LEN) % BLOCK != 0 { + return None; + } + let (nonce, sealed) = body.split_at(NONCE_LEN); + let mut padded = self.aead.decrypt(Nonce::from_slice(nonce), sealed).ok()?; + let pad = *padded.last()? as usize; + if pad == 0 + || pad > BLOCK + || padded[padded.len() - pad..] + .iter() + .any(|b| *b as usize != pad) + { + return None; + } + padded.truncate(padded.len() - pad); + Some(padded) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &str = "workspace-key"; + + #[test] + fn v2_round_trips() { + let wc = WorkspaceCrypt::new(KEY); + for plain in [ + "", + "s", + "exactly sixteen!", + "a longer secret value with ünïcödé", + ] { + let a = wc.encrypt_str_in(plain, CipherFormat::V2); + assert_ne!(a, wc.encrypt_str_in(plain, CipherFormat::V2), "nonce reuse"); + assert!(wc.is_v2(&a)); + assert_eq!(wc.decrypt_str(&a).unwrap(), plain); + } + } + + #[test] + fn legacy_cbc_still_decrypts() { + let wc = WorkspaceCrypt::new(KEY); + let legacy = magic_crypt::new_magic_crypt!(KEY, 256).encrypt_str_to_base64("old secret"); + assert!(!wc.is_v2(&legacy)); + assert_eq!(wc.decrypt_str(&legacy).unwrap(), "old secret"); + let upgraded = wc.upgrade(&legacy).unwrap(); + assert!(wc.is_v2(&upgraded)); + assert_eq!(wc.decrypt_str(&upgraded).unwrap(), "old secret"); + assert_eq!(wc.upgrade(&upgraded), None); + } + + #[test] + fn magic_prefixed_cbc_still_decrypts() { + // Too rare to find by search, so built: with magic_crypt's zero IV and SHA-256(key) AES + // key, the first CBC block is AES(P1), so P1 = AES^-1(MAGIC || zeros). + let mut block = [0u8; 16]; + block[..4].copy_from_slice(&V2_MAGIC); + let mut plaintext = { + use aes_gcm::aes::cipher::{BlockDecrypt, KeyInit}; + use sha2::Digest; + let cipher = aes_gcm::aes::Aes256::new_from_slice(&Sha256::digest(KEY)).unwrap(); + let mut b = aes_gcm::aes::Block::clone_from_slice(&block); + cipher.decrypt_block(&mut b); + b.to_vec() + }; + plaintext.extend_from_slice(b"tail of the secret value"); + let ciphertext = magic_crypt::new_magic_crypt!(KEY, 256).encrypt_bytes_to_bytes(&plaintext); + assert!(ciphertext.starts_with(&V2_MAGIC)); + assert_eq!( + WorkspaceCrypt::new(KEY).decrypt_bytes(&ciphertext).unwrap(), + plaintext + ); + } + + #[test] + fn v2_passes_the_block_multiple_ciphertext_shape_check() { + let wc = WorkspaceCrypt::new(KEY); + for len in 0..64 { + let decoded = B64 + .decode(wc.encrypt_str_in(&"x".repeat(len), CipherFormat::V2)) + .unwrap(); + assert!(!decoded.is_empty() && decoded.len() % 16 == 0, "len {len}"); + } + } +} diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 8f288dc85e..151b75bd85 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -563,7 +563,8 @@ async fn check_path_conflict(db: &DB, w_id: &str, path: &str) -> Result<()> { /// /// The check is purely structural and never decrypts, so it cannot act as a /// decryption/padding oracle for a caller who can write but not read secrets. -/// `encrypt` (AES-256-CBC) always yields standard base64 decoding to a non-zero +/// `encrypt` (AES-256-GCM, or legacy AES-256-CBC; see `windmill_common::workspace_crypt`) +/// always yields standard base64 decoding to a non-zero /// multiple of the 16-byte block size; anything else cannot be our ciphertext. /// Values stored by an external backend ($vault:/$aws_sm:/$azure_kv: markers) /// are not workspace ciphertext and are passed through untouched. diff --git a/cli/src/commands/variable/variable.ts b/cli/src/commands/variable/variable.ts index 8963f376d2..88ad108a1a 100644 --- a/cli/src/commands/variable/variable.ts +++ b/cli/src/commands/variable/variable.ts @@ -107,7 +107,8 @@ export interface VariableFile { * plaintext value a user authored by hand. * * Mirrors the server guard (windmill-store/src/variables.rs): workspace - * ciphertext (AES-256-CBC, base64) is standard base64 decoding to a non-zero + * ciphertext (AES-256-GCM or legacy AES-256-CBC, base64) is standard base64 + * decoding to a non-zero * multiple of the 16-byte block size. External secret-backend markers * ($vault:/$aws_sm:/$azure_kv:) are stored verbatim too, so they count as * already-encrypted. This is a shape check only — it never decrypts. From add042ef2f05828a8ea5f6d0a089204bae2322d5 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Tue, 13 Oct 2026 02:52:58 -0400 Subject: [PATCH 169/174] fix: end other sessions when a user changes their password (#11710) Co-authored-by: Claude Opus 5.5 (1M context) --- ...db63d19f1c9ab0366e01c0092aa653ab51877.json | 15 ++++++++++++++ backend/windmill-api/src/users.rs | 20 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 backend/.sqlx/query-c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877.json diff --git a/backend/.sqlx/query-c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877.json b/backend/.sqlx/query-c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877.json new file mode 100644 index 0000000000..be4fbb73c9 --- /dev/null +++ b/backend/.sqlx/query-c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE email = $1 AND label = 'session' AND token_hash != $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c3c51c3536eaa6a5e702d306344db63d19f1c9ab0366e01c0092aa653ab51877" +} diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 39a213c860..5b26cf57b8 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -198,6 +198,7 @@ async fn set_password( Extension(db): Extension, Extension(argon2): Extension>>, authed: ApiAuthed, + Tokened { token }: Tokened, OptJobAuthed { job_id, .. }: OptJobAuthed, Json(ep): Json, ) -> Result { @@ -206,7 +207,15 @@ async fn set_password( // (GHSA-hfh4-cx4h-3fcr). forbid_elevated_job_token(&db, &authed.email, job_id).await?; let email = authed.email.clone(); - crate::users_oss::set_password(db, argon2, authed, &email, ep).await + let res = crate::users_oss::set_password(db.clone(), argon2, authed, &email, ep).await?; + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session' AND token_hash != $2", + &email, + windmill_common::auth::hash_token(&token) + ) + .execute(&db) + .await?; + Ok(res) } async fn set_password_of_user( @@ -219,7 +228,14 @@ async fn set_password_of_user( ) -> Result { require_super_admin(&db, &authed).await?; forbid_superadmin_job_token(&db, &authed.email, job_id).await?; - crate::users_oss::set_password(db, argon2, authed, &email, ep).await + let res = crate::users_oss::set_password(db.clone(), argon2, authed, &email, ep).await?; + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + &email + ) + .execute(&db) + .await?; + Ok(res) } #[derive(Deserialize)] From 7bbe03227d56688893aafda305a13d65b2bb84f4 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 08:55:31 +0200 Subject: [PATCH 170/174] fix: bump git sync hub scripts to cli 1.830.0 (#11681) Claude-Session: https://claude.ai/code/session_011BzU9zd53JsTShCsbKzYzB Co-authored-by: Claude Opus 5.5 (1M context) --- backend/windmill-common/src/workspaces.rs | 4 ++-- frontend/src/lib/hubPaths.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index f819c5b796..a02bd7f44e 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -184,7 +184,7 @@ pub enum ObjectType { DatatableMigration, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28994/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/29151/sync-script-to-git-repo-windmill"; /// Hub script that applies a repository's state back into a workspace /// (the repo → Windmill / "pull" direction). Same script the UI runs from @@ -192,7 +192,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28994/sync-script-to-git-repo /// ignores the slug, so the slug is kept free of characters that would be /// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened /// reverse proxies reject as double-encoding when the client re-encodes it). -pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28993/git-sync-init-repository-windmill"; +pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/29150/git-sync-init-repository-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 2c46c48b2c..7e1f965c36 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,6 +1,6 @@ { "gitSyncTest": "hub/28950/git-repo-test-read-write-windmill", - "gitInitRepo": "hub/28993/git-sync-init-repository-windmill", + "gitInitRepo": "hub/29150/git-sync-init-repository-windmill", "slackErrorHandler": "hub/28794/workspace-or-schedule-error-handler-slack", "emailErrorHandler": "hub/19795/workspace-or-error-handler-email", "slackRecoveryHandler": "hub/28791/slack/schedule-recovery-handler-slack", From aeeeb042f93df32214e00040d360f20e8ed8c98a Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 13 Oct 2026 08:56:31 +0200 Subject: [PATCH 171/174] fix: persist hidden languages in edit default scripts drawer (#11693) Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/lib/components/DefaultScriptsInner.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/DefaultScriptsInner.svelte b/frontend/src/lib/components/DefaultScriptsInner.svelte index 48c7a8d9f7..29136e42f7 100644 --- a/frontend/src/lib/components/DefaultScriptsInner.svelte +++ b/frontend/src/lib/components/DefaultScriptsInner.svelte @@ -33,6 +33,10 @@ ;[norder[i], norder[i + 1]] = [norder[i + 1], norder[i]] } defaultScripts.update((s) => ({ ...s, order: norder })) + await persist() + } + + async function persist() { await WorkspaceService.editDefaultScripts({ workspace: $operatingWorkspace!, requestBody: $defaultScripts @@ -78,7 +82,7 @@ size="xs" color="red" checked={$defaultScripts?.hidden?.includes(lang)} - on:change={(e) => { + on:change={async (e) => { let toggled = e.detail if (toggled) { defaultScripts.update((s) => ({ @@ -91,6 +95,7 @@ hidden: (s?.hidden ?? []).filter((h) => h != lang) })) } + await persist() }} />
From cd502a5270e787aeb1e5cb14e5a0e0aad8bb97b3 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:59:39 +0200 Subject: [PATCH 172/174] fix: resolve a new item's chosen name in session preview tools (#11688) Co-authored-by: Claude Opus 5.5 --- .../sessions/sessionRuntime.svelte.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index b2a37f1ca0..2995366ae0 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -103,6 +103,7 @@ import { type RawAppRunsProvider, type RawAppScreenshotRequester } from '$lib/components/raw_apps/utils' +import { resolveGlobalDraftStoragePathByKind } from '$lib/components/copilot/chat/global/userDraftAdapter' import type { RawAppDomQuery, RawAppDomRequester, @@ -1120,7 +1121,7 @@ function createRuntime(session: Session): SessionRuntime { }, async requestRuntimeLogs(limit, appPath) { const path = - appPath ?? + (appPath && draftStoragePath(session, 'raw_app', appPath)) ?? activeDomAppPath ?? (runtimeLogRequesters.size === 1 ? [...runtimeLogRequesters.keys()][0] : undefined) if (path === undefined) { @@ -1156,7 +1157,7 @@ function createRuntime(session: Session): SessionRuntime { // its element's app even when another tab is now visible), else the // active preview, else the only one open. const path = - query.appPath ?? + (query.appPath && draftStoragePath(session, 'raw_app', query.appPath)) ?? activeDomAppPath ?? (domRequesters.size === 1 ? [...domRequesters.keys()][0] : undefined) if (path === undefined) return undefined @@ -1372,6 +1373,18 @@ export function removeSession(sessionId: string): void { deleteSessionState(sessionId) } +// The chat calls a new item by its chosen name while its draft is stored at +// `draft_`; resolve the name the way the chat's write tools do. +function draftStoragePath( + session: Session, + kind: 'script' | 'flow' | 'raw_app', + path: string +): string { + return session.workspace_id + ? resolveGlobalDraftStoragePathByKind(session.workspace_id, kind, path) + : path +} + // Register the global open_preview tool handler once at module load. It // dispatches to the *calling* session (sessionId threaded from the tool ctx), // falling back to the UI-active session only when none was passed — so a @@ -1387,7 +1400,10 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path, mode }) = if (!session) { return 'Error: no active session to open the preview in.' } - const target = previewTargetForSessionTarget(kind, path) + const target = previewTargetForSessionTarget( + kind, + mode === 'view' || kind === 'pipeline' ? path : draftStoragePath(session, kind, path) + ) if (!target) { return `Error: ${kind} targets cannot be shown in the preview panel.` } From b57fd3e29a8ff3633a585a8cc07059d45c33c9f6 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 09:27:24 +0200 Subject: [PATCH 173/174] chore: publish the chat sdk as @windmill-labs/chat-sdk (#11686) * chore: publish the chat sdk as @windmill-labs/chat-sdk * chore: publish the chat sdk with a dedicated npm token * chore: publish the chat sdk with npm trusted publishing --- .github/workflows/npm_on_release.yml | 11 +++++--- chat-sdk/README.md | 28 +++++++++---------- chat-sdk/package-lock.json | 4 +-- chat-sdk/package.json | 2 +- cli/src/guidance/skills.gen.ts | 8 +++--- system_prompts/auto-generated/prompts.ts | 8 +++--- .../auto-generated/skills/raw-app/SKILL.md | 8 +++--- system_prompts/base/raw-app.md | 8 +++--- 8 files changed, 40 insertions(+), 37 deletions(-) diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 631ab53153..a2bd974daa 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -18,16 +18,19 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_chat_sdk: - runs-on: ubicloud-standard-8 + # Published by npm trusted publishing (OIDC), which npm accepts only from + # GitHub-hosted runners and npm >= 11.5.1: keep both, there is no token to fall back on. + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v7 with: - node-version: "20.x" + node-version: "24.x" registry-url: "https://registry.npmjs.org" - run: cd chat-sdk && npm ci && npm run build && npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_cli: runs-on: ubicloud-standard-8 steps: diff --git a/chat-sdk/README.md b/chat-sdk/README.md index 62a5467a47..006d5407d8 100644 --- a/chat-sdk/README.md +++ b/chat-sdk/README.md @@ -1,21 +1,21 @@ -# windmill-chat +# @windmill-labs/chat-sdk Build a chat interface on a Windmill flow deployed in **chat mode**, from any frontend or from a Windmill raw app. The library is headless: it runs the flow, follows the answer as it streams, keeps the conversation history, and hands you state to render. ``` -npm install windmill-chat +npm install @windmill-labs/chat-sdk ``` -No runtime dependencies. Optional peers: `react` for `windmill-chat/react`, `ai` for -`windmill-chat/ai-sdk`, `@assistant-ui/react` for `windmill-chat/assistant-ui`. +No runtime dependencies. Optional peers: `react` for `@windmill-labs/chat-sdk/react`, `ai` for +`@windmill-labs/chat-sdk/ai-sdk`, `@assistant-ui/react` for `@windmill-labs/chat-sdk/assistant-ui`. | You build the UI with | Import | You get | |---|---|---| -| Vercel AI SDK `useChat`, AI Elements | `windmill-chat/ai-sdk` | a `ChatTransport`: `useChat({ transport })`, nothing else changes | -| assistant-ui | `windmill-chat/assistant-ui` | a runtime for `AssistantRuntimeProvider`, threads included | -| Your own components | `windmill-chat/react` or `windmill-chat` | a hook / a store with messages, status and actions | +| Vercel AI SDK `useChat`, AI Elements | `@windmill-labs/chat-sdk/ai-sdk` | a `ChatTransport`: `useChat({ transport })`, nothing else changes | +| assistant-ui | `@windmill-labs/chat-sdk/assistant-ui` | a runtime for `AssistantRuntimeProvider`, threads included | +| Your own components | `@windmill-labs/chat-sdk/react` or `@windmill-labs/chat-sdk` | a hook / a store with messages, status and actions | ## The flow @@ -31,7 +31,7 @@ so an AI agent step remembers earlier turns. The answer is: ```tsx import { useChat } from '@ai-sdk/react' -import { createWindmillChatTransport } from 'windmill-chat/ai-sdk' +import { createWindmillChatTransport } from '@windmill-labs/chat-sdk/ai-sdk' const transport = createWindmillChatTransport({ baseUrl: 'https://app.windmill.dev', @@ -65,7 +65,7 @@ explanatory error. ```tsx import { AssistantRuntimeProvider } from '@assistant-ui/react' -import { useWindmillRuntime } from 'windmill-chat/assistant-ui' +import { useWindmillRuntime } from '@windmill-labs/chat-sdk/assistant-ui' export function Support() { const runtime = useWindmillRuntime({ baseUrl, workspace, flowPath, token }) @@ -84,7 +84,7 @@ through `Reasoning`. It takes the same options as `useWindmillChat` below. ## React ```tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' export function Support() { const chat = useWindmillChat({ @@ -155,7 +155,7 @@ change, and returns the unsubscribe function. That is the Svelte store contract, `$chat` works as is; other frameworks wrap it in a few lines. ```ts -import { createChat } from 'windmill-chat' +import { createChat } from '@windmill-labs/chat-sdk' const chat = createChat({ baseUrl, workspace, flowPath, token }) chat.subscribe((state) => render(state)) @@ -164,7 +164,7 @@ await chat.sendMessage('Hello') ```svelte @@ -331,8 +331,8 @@ custom integrations. ## For AI coding agents When asked to add a chat over a Windmill flow: the flow must be deployed with chat mode -on. Pick the entry point from the table at the top (`useChat` → `windmill-chat/ai-sdk`, -assistant-ui → `windmill-chat/assistant-ui`, otherwise `windmill-chat/react`). Inside a +on. Pick the entry point from the table at the top (`useChat` → `@windmill-labs/chat-sdk/ai-sdk`, +assistant-ui → `@windmill-labs/chat-sdk/assistant-ui`, otherwise `@windmill-labs/chat-sdk/react`). Inside a Windmill raw app pass only `flowPath`. Elsewhere pass `baseUrl`, `workspace` and a `token`; for a public page use a token scoped to `jobs:run:flows:` and leave `history` at its default. Render `role`, `content`, `pending`, `success` and diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 03a55cff86..74f6914f37 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,11 +1,11 @@ { - "name": "windmill-chat", + "name": "@windmill-labs/chat-sdk", "version": "1.830.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "windmill-chat", + "name": "@windmill-labs/chat-sdk", "version": "1.830.0", "license": "Apache-2.0", "devDependencies": { diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 96637c376c..26f5f785be 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,5 +1,5 @@ { - "name": "windmill-chat", + "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", "author": "Ruben Fiszel", diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 8491e837e3..ab4ef31c3e 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6747,16 +6747,16 @@ Apply it to customer data, internal notes and anything else a viewer of the demo ### Chat UIs over a flow in chat mode -A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`windmill-chat\` to \`package.json\` and use it directly, it detects the app's Windmill and credential. +A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`@windmill-labs/chat-sdk\` to \`package.json\` and use it directly, it detects the app's Windmill and credential. \`\`\`tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) // chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop() \`\`\` -\`windmill-chat/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`windmill-chat/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser. +\`@windmill-labs/chat-sdk/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`@windmill-labs/chat-sdk/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser. ## Backend runnables @@ -6916,7 +6916,7 @@ def main(user_id: str): 7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use \`windmill-chat\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. `, "triggers": `--- name: triggers diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 1c6541e155..097a7af895 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1024,16 +1024,16 @@ Apply it to customer data, internal notes and anything else a viewer of the demo ### Chat UIs over a flow in chat mode -A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`windmill-chat\` to \`package.json\` and use it directly, it detects the app's Windmill and credential. +A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add \`@windmill-labs/chat-sdk\` to \`package.json\` and use it directly, it detects the app's Windmill and credential. \`\`\`tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) // chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop() \`\`\` -\`windmill-chat/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`windmill-chat/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser. +\`@windmill-labs/chat-sdk/ai-sdk\` gives a \`ChatTransport\` for the Vercel AI SDK's \`useChat\`, \`@windmill-labs/chat-sdk/assistant-ui\` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs \`jobs:run\` in its frontend SDK scopes, plus \`flow_conversations:write\` for the conversation sidebar; without them the chat keeps history in the browser. ## Backend runnables @@ -1182,7 +1182,7 @@ def main(user_id: str): 7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use \`windmill-chat\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. `; export const PIPELINE_BASE = `# Data pipeline authoring diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 8d7788dd94..7afcbd2969 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -378,16 +378,16 @@ Apply it to customer data, internal notes and anything else a viewer of the demo ### Chat UIs over a flow in chat mode -A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `windmill-chat` to `package.json` and use it directly, it detects the app's Windmill and credential. +A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential. ```tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) // chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop() ``` -`windmill-chat/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `windmill-chat/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser. +`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser. ## Backend runnables @@ -547,4 +547,4 @@ def main(user_id: str): 7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use `windmill-chat` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index dea8ebd21e..23b09f1996 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -139,16 +139,16 @@ Apply it to customer data, internal notes and anything else a viewer of the demo ### Chat UIs over a flow in chat mode -A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `windmill-chat` to `package.json` and use it directly, it detects the app's Windmill and credential. +A flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential. ```tsx -import { useWindmillChat } from 'windmill-chat/react' +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) // chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop() ``` -`windmill-chat/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `windmill-chat/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser. +`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser. ## Backend runnables @@ -319,4 +319,4 @@ def main(user_id: str): 7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use `windmill-chat` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. From a298ed3fc19a1b7fc4f8e75a95c8d38652b74b5b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 11:01:21 +0200 Subject: [PATCH 174/174] feat: add the viewer's roles to unnamed data table connections (#11709) * feat: connect to a data table with every role of the job's viewer Co-Authored-By: Claude Opus 5.5 * fix: key a cached result by the run's recorded viewer Co-Authored-By: Claude Opus 5.5 * test: pin the per-viewer cache key and fix a stale doc sentence Co-Authored-By: Claude Opus 5.5 * fix: length-prefix the viewer in the result cache key Co-Authored-By: Claude Opus 5.5 * feat: drop data table role set logins unused for 30 days Co-Authored-By: Claude Opus 5.5 * chore: pin the ee ref with the set login sweep fixes Co-Authored-By: Claude Opus 5.5 * chore: pin the ee ref with the sweep retry fix Co-Authored-By: Claude Opus 5.5 * test: give the cache key test its own migrated database Co-Authored-By: Claude Opus 5.5 * feat: add the viewer's roles to a data table connection that names none The default role of a data table under roles is now a floor: a connection naming no role also holds the roles its viewer is a tenant of, unless the default is admin. This replaces the explicit as=viewer option, which is removed from the reference parser, the executors and both clients. The roles drawer warns while the default is admin and confirms before removing the default role. Co-Authored-By: Claude Opus 5.5 * feat: let a data table have no default role A connection naming no role then holds its viewer's roles only and is refused without any. The roles drawer offers the choice, states what an unnamed connection gets for the current settings, says who gets admin while it is the default, and notes who owns a table created over several roles. Co-Authored-By: Claude Opus 5.5 * feat: start a data table's roles with admin open to everyone Turning roles on from the drawer seeded admin with no tenant, which refused every non-admin member's job as soon as it was saved. It now starts open to everyone, so the toggle changes nothing until the roles are edited, and the drawer warns for as long as everyone can connect as admin. Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 40ab35596e6fcb82a4dfc56e186befb807f8e2af This commit updates the EE repository reference after PR #873 was merged in windmill-ee-private. Previous ee-repo-ref: a5def184a29fffab8ed42c70c5a7445b2b3cbe98 New ee-repo-ref: 40ab35596e6fcb82a4dfc56e186befb807f8e2af Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- AGENTS.md | 3 + backend/ee-repo-ref.txt | 2 +- ...20261012180101_datatable_role_set.down.sql | 1 + .../20261012180101_datatable_role_set.up.sql | 21 ++ backend/src/monitor.rs | 19 ++ .../tests/datatable_roles.rs | 219 ++++++++++++++++++ backend/windmill-api/openapi.yaml | 4 + backend/windmill-api/src/jobs.rs | 13 +- .../windmill-common/src/datatable_roles.rs | 17 ++ .../src/datatable_roles_oss.rs | 8 +- backend/windmill-common/src/jobs.rs | 4 + backend/windmill-common/src/workspaces.rs | 18 +- backend/windmill-worker/src/common.rs | 63 +++++ .../windmill-worker/src/duckdb_executor.rs | 1 + backend/windmill-worker/src/pg_executor.rs | 1 + backend/windmill-worker/src/worker.rs | 6 +- cli/src/guidance/skills.gen.ts | 3 +- docs/datatable-viewer-roles.md | 93 ++++++++ frontend/src/lib/components/DBManager.svelte | 12 +- frontend/src/lib/components/dbTypes.ts | 11 +- .../raw_apps/RawAppDataTableDrawer.svelte | 7 +- .../DataTablePermissionsButton.svelte | 107 ++++++++- system_prompts/auto-generated/prompts.ts | 1 + .../auto-generated/skills/raw-app/SKILL.md | 3 +- system_prompts/base/raw-app-cli.md | 2 +- system_prompts/base/raw-app.md | 1 + 26 files changed, 612 insertions(+), 28 deletions(-) create mode 100644 backend/migrations/20261012180101_datatable_role_set.down.sql create mode 100644 backend/migrations/20261012180101_datatable_role_set.up.sql create mode 100644 docs/datatable-viewer-roles.md diff --git a/AGENTS.md b/AGENTS.md index fd4b60a64b..a57215cde5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,9 @@ Open-source platform for internal tools, workflows, API integrations, background administers behind `external_instance` data tables and Ducklake catalogs: its invariants (one lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership) and how to run one locally +- **Data table viewer roles**: `docs/datatable-viewer-roles.md`, on what a connection naming no + role holds: the default role as a floor, where the viewer comes from, why an admin's roles are + not the viewer's, the per-role-set logins and the order their rows and logins are written in - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option` diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 4c3f6abd98..695f6d248b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -1e538620a9b2f08b044997d032e71d8720ecbd5c +40ab35596e6fcb82a4dfc56e186befb807f8e2af diff --git a/backend/migrations/20261012180101_datatable_role_set.down.sql b/backend/migrations/20261012180101_datatable_role_set.down.sql new file mode 100644 index 0000000000..15384edae8 --- /dev/null +++ b/backend/migrations/20261012180101_datatable_role_set.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS datatable_role_set; diff --git a/backend/migrations/20261012180101_datatable_role_set.up.sql b/backend/migrations/20261012180101_datatable_role_set.up.sql new file mode 100644 index 0000000000..0093837d3b --- /dev/null +++ b/backend/migrations/20261012180101_datatable_role_set.up.sql @@ -0,0 +1,21 @@ +-- The logins behind a data table connection made as several roles at once: one Postgres login per +-- distinct set of data table roles, a member of each. Which roles a login holds is its +-- memberships on the cluster, and its name is derived from their catalog ids, so a row only has to +-- carry the credential. +-- +-- A row is written after its login exists and removed before the login is dropped, so a row always +-- names a login that can authenticate with `pwd`. +CREATE TABLE datatable_role_set ( + cluster VARCHAR(20) NOT NULL CHECK (cluster IN ('instance', 'external_instance')), + name VARCHAR(63) NOT NULL, + -- Generated by Windmill, never entered by anyone, and never leaves the server. + pwd TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Recorded at most once a day. A login unused for long enough is dropped, and made again if + -- its set of roles is asked for later. + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (cluster, name) +); + +GRANT ALL ON datatable_role_set TO windmill_user; +GRANT ALL ON datatable_role_set TO windmill_admin; diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 24f9d8221c..23ab2eaed1 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -4449,6 +4449,24 @@ pub async fn monitor_db( } }; + // Drop the data table role set logins nobody has connected with for a month. Every ~40 min, + // spawned for the same reason as the credential maintenance above: it opens a connection per + // managed database for each login it drops. + let datatable_role_set_sweep_f = async { + if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(240) { + if let Some(db) = conn.as_sql() { + let db = db.clone(); + tokio::spawn(async move { + if let Err(e) = + windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await + { + tracing::error!("Error sweeping unused data table role set logins: {e}"); + } + }); + } + } + }; + // Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM. // Every ~5 min until one pass has gone through every workspace; spawned so a large instance // does not hold up the join below. @@ -4538,6 +4556,7 @@ pub async fn monitor_db( cleanup_scheduled_job_deletions_f, git_auto_pull_f, git_credential_maintenance_f, + datatable_role_set_sweep_f, ai_session_retention_f, legacy_secret_upgrade_f, pipeline_freshness_watchdog_f, diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 9800d97841..023d9f20ed 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -1847,3 +1847,222 @@ async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database( assert_eq!(users, vec!["test-workspace".to_string()]); Ok(()) } + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess}; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); + let names = [ + format!("wmtest_floor_{suffix}"), + format!("wmtest_emea_{suffix}"), + format!("wmtest_amer_{suffix}"), + ]; + let roles_url = format!("http://localhost:{port}/api/settings/datatable_roles"); + const MEMBER_LOGINS: &str = "SELECT m.rolname::text FROM pg_auth_members am + JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member + WHERE r.rolname = $1 AND m.rolname LIKE 'wm\\_set\\_%'"; + + // The job is the app publisher's in every case: only the viewer the server recorded differs. + let connect = |role: Option<&'static str>, viewer: Option<&'static str>| { + get_datatable_resource_from_db( + &db, + "test-workspace", + "main", + role, + DatatableAccess::PermissionedAs { + permissioned_as: "u/test-user", + email: "test@windmill.dev", + end_user_email: viewer, + }, + ) + }; + let set_permissions = |default_role: String, roles: Value| { + sqlx::query( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, '{datatables,main,permissions}', $1) + WHERE workspace_id = 'test-workspace'", + ) + .bind(json!({ "default_role": default_role, "roles": roles })) + .execute(&db) + }; + + let outcome = async { + let mut ids = vec![]; + for name in &names { + let created: Value = authed(client().post(&roles_url), "SECRET_TOKEN") + .json(&json!({ "name": name })) + .send() + .await? + .error_for_status()? + .json() + .await?; + ids.push(created["id"].as_str().unwrap().to_string()); + } + // `test-user-2` is named by both regional roles, one of them through a group, and by + // `admin`; `test-user-3` by one; the workspace admin `test-user` by neither. + let roles = json!({ + "admin": {"tenants": ["u/test-user-2"]}, + (ids[0].as_str()): {"tenants": ["*"]}, + (ids[1].as_str()): {"tenants": ["u/test-user-2", "u/test-user-3"]}, + (ids[2].as_str()): {"tenants": ["g/analysts"]}, + }); + set_permissions(ids[0].clone(), roles.clone()).await?; + sqlx::query( + "INSERT INTO usr_to_group (workspace_id, usr, group_) + VALUES ('test-workspace', 'test-user-2', 'analysts')", + ) + .execute(&db) + .await?; + let member_of = |login: String| async { + let mut roles: Vec = sqlx::query_scalar( + "SELECT r.rolname::text FROM pg_auth_members am + JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member + WHERE m.rolname = $1", + ) + .bind(login) + .fetch_all(&db) + .await?; + roles.sort(); + Ok::<_, anyhow::Error>(roles) + }; + + let all = connect(None, Some("test2@windmill.dev")).await?; + let login = all["user"].as_str().unwrap().to_string(); + assert!(login.starts_with("wm_set_"), "{login}"); + let mut expected = names.to_vec(); + expected.sort(); + assert_eq!( + member_of(login.clone()).await?, + expected, + "the login holds the default role and the viewer's, and not `admin`" + ); + assert_eq!(connect(None, Some("test2@windmill.dev")).await?, all); + + let two = connect(None, Some("test3@windmill.dev")).await?; + let mut expected = names[..2].to_vec(); + expected.sort(); + assert_eq!( + member_of(two["user"].as_str().unwrap().to_string()).await?, + expected + ); + + // An admin may use every role, the one owning the tables included. As a viewer they add + // what names them, which here is nothing; nor does a job with no viewer recorded. + for viewer in [Some("test@windmill.dev"), None] { + assert_eq!(connect(None, viewer).await?["user"], names[0].as_str()); + } + // A named role is that role alone, whoever is viewing. + let named: &'static str = Box::leak(names[1].clone().into_boxed_str()); + assert_eq!( + connect(Some(named), Some("test2@windmill.dev")).await?["user"], + names[1].as_str() + ); + // A default of `admin` is the connection it always was: the viewer's roles are not added. + set_permissions("admin".to_string(), roles.clone()).await?; + let admin = connect(None, Some("test2@windmill.dev")).await?; + assert_eq!(admin, connect(Some("admin"), None).await?); + assert!(!admin["user"].as_str().unwrap().starts_with("wm_")); + + // With no default role the viewer's roles are all there is, the one open to everyone + // included, and a connection left with none is refused rather than resolved as anything. + set_permissions("$none".to_string(), roles.clone()).await?; + assert_eq!( + connect(None, Some("test3@windmill.dev")).await?, + two, + "no default changes nothing for a viewer the roles name" + ); + assert!(connect(None, None).await.is_err()); + // The same holds for a default the job's own identity is not a tenant of. + let mut closed = roles.clone(); + closed[ids[0].as_str()] = json!({"tenants": []}); + set_permissions(ids[0].clone(), closed).await?; + let as_member = |viewer: Option<&'static str>| { + get_datatable_resource_from_db( + &db, + "test-workspace", + "main", + None, + DatatableAccess::PermissionedAs { + permissioned_as: "u/test-user-3", + email: "test3@windmill.dev", + end_user_email: viewer, + }, + ) + }; + assert_eq!( + as_member(Some("test3@windmill.dev")).await?["user"], + names[1].as_str() + ); + assert!(as_member(None).await.is_err()); + set_permissions(ids[0].clone(), roles).await?; + + // A set login nobody connects with any more is dropped, a recent one is kept, and a + // dropped one is made again for the next viewer who needs it. + let exists = || async { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", + ) + .bind(&login) + .fetch_one(&db) + .await + }; + windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await?; + assert!(exists().await?, "a set login in use was swept"); + sqlx::query("UPDATE datatable_role_set SET last_used_at = now() - interval '31 days'") + .execute(&db) + .await?; + windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await?; + assert!(!exists().await?, "an unused set login was kept"); + assert_eq!( + connect(None, Some("test2@windmill.dev")).await?["user"], + login.as_str() + ); + + // A disabled role must not stay reachable through a login that is a member of it. + authed( + client().post(format!("{roles_url}/{}", ids[2])), + "SECRET_TOKEN", + ) + .json(&json!({ "enabled": false })) + .send() + .await? + .error_for_status()?; + let can_login: bool = + sqlx::query_scalar("SELECT rolcanlogin FROM pg_roles WHERE rolname = $1") + .bind(&login) + .fetch_one(&db) + .await?; + assert!(!can_login, "{login} still logs in with a disabled role"); + assert_eq!( + connect(None, Some("test2@windmill.dev")).await?["user"], + two["user"] + ); + Ok::<_, anyhow::Error>(()) + } + .await; + + // Roles are cluster-wide, so they outlive this test's throwaway database. + for name in &names { + let logins: Vec = sqlx::query_scalar(MEMBER_LOGINS) + .bind(name) + .fetch_all(&db) + .await + .unwrap_or_default(); + for login in logins { + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{login}\"")) + .execute(&db) + .await; + } + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\"")) + .execute(&db) + .await; + } + outcome +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ae01930182..433d7c00be 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5568,6 +5568,9 @@ paths: type: boolean default_role: type: string + description: >- + The id of one of `roles`, `admin` (the default), or `$none` for no default + role: a connection that names no role then holds its viewer's roles only. roles: type: array items: @@ -34782,6 +34785,7 @@ components: type: boolean default_role: type: string + description: A role id, `admin`, or `$none` when the data table has no default role. roles: type: array items: diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 29268d3cb2..2b93c6daeb 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -8982,6 +8982,7 @@ async fn run_preview_script( check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; + let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?; let (uuid, tx) = push( &db, tx, @@ -9029,7 +9030,7 @@ async fn run_preview_script( None, Some(&authed.clone().into()), false, - None, + end_user_email, authed.trigger_or_fallback(None), None, scope_ceiling.as_deref(), @@ -9068,6 +9069,7 @@ async fn run_inline_preview_script( if let Some(job_id) = job_id { register_potential_assets_on_inline_execution(job_id, &w_id, &preview); } + let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?; let utils = get_worker_internal_server_inline_utils()?; let result = utils.run_inline_preview_script.as_ref()(RunInlinePreviewScriptFnParams { content: preview.content, @@ -9078,6 +9080,7 @@ async fn run_inline_preview_script( created_by: authed.display_username().to_string(), permissioned_as: username_to_permissioned_as(&authed.username), permissioned_as_email: authed.email.clone(), + end_user_email, lang: preview.language, job_dir: "".to_string(), worker_name: "".to_string(), @@ -9217,6 +9220,7 @@ async fn run_inline_script_inner( .to_string(), )); } + let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?; let utils = get_worker_internal_server_inline_utils()?; let authed_owned: windmill_common::db::Authed = authed.clone().into(); let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams { @@ -9228,6 +9232,7 @@ async fn run_inline_script_inner( created_by: authed.display_username().to_string(), permissioned_as: username_to_permissioned_as(&authed.username), permissioned_as_email: authed.email.clone(), + end_user_email, job_dir: "".to_string(), worker_name: "".to_string(), worker_dir: "".to_string(), @@ -9404,6 +9409,7 @@ async fn run_bundle_preview_script( // tracing::info!("is_tar 1: {is_tar}"); // hmap.insert("") let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; + let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?; let (uuid, ntx) = push( &db, ltx, @@ -9447,7 +9453,7 @@ async fn run_bundle_preview_script( None, Some(&authed.clone().into()), false, - None, + end_user_email, authed.trigger_or_fallback(None), None, scope_ceiling.as_deref(), @@ -10151,6 +10157,7 @@ async fn run_preview_flow_job( .await?; let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; + let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?; let (uuid, mut tx) = push( &db, tx, @@ -10182,7 +10189,7 @@ async fn run_preview_flow_job( None, Some(&authed.clone().into()), false, - None, + end_user_email, authed.trigger_or_fallback(None), None, scope_ceiling.as_deref(), diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index e7e490a6a4..522327040d 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -72,6 +72,11 @@ impl DatatableRoleCluster { /// created, renamed or dropped. pub const ADMIN_DATATABLE_ROLE: &str = "admin"; +/// Stands in a data table's `default_role` for "none": a connection that names no role then holds +/// its viewer's roles only, and is refused without any. `$` is outside both the role-name charset +/// and the catalog ids, and a server predating it refuses it as a role the data table lacks. +pub const NO_DEFAULT_DATATABLE_ROLE: &str = "$none"; + /// The login the admin connection uses, and the role every created role is granted to — that /// membership is what later lets it `ALTER ... OWNER TO` a role and drop it. pub const CUSTOM_INSTANCE_USER: &str = "custom_instance_user"; @@ -121,8 +126,13 @@ fn is_reserved_role_name(name: &str) -> bool { || lower.starts_with("pg_") || lower.starts_with("windmill_") || lower.starts_with("custom_instance_") + || lower.starts_with(ROLE_SET_LOGIN_PREFIX) } +/// Prefix of the logins Windmill creates for a connection made as several roles at once. Reserved, +/// so no data table role can be created under, renamed to or taken over at such a name. +pub const ROLE_SET_LOGIN_PREFIX: &str = "wm_set_"; + /// The charset is what makes every downstream interpolation safe: the name reaches Postgres as a /// quoted identifier, a `-- role ` annotation, and a `?role=` query parameter. pub fn validate_role_name(name: &str) -> Result<()> { @@ -414,6 +424,13 @@ pub async fn drop_datatable_role( crate::datatable_roles_oss::drop_datatable_role(db, tx, cluster, name).await } +/// Drop the logins made for a connection as several roles at once that nobody has connected with +/// for a month, on every cluster. They are made again on demand, so this only bounds how many +/// exist. Safe to call from any server at any time: it serializes on the role catalog lock. +pub async fn sweep_unused_role_set_logins(db: &DB) -> Result<()> { + crate::datatable_roles_oss::sweep_unused_role_set_logins(db).await +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/windmill-common/src/datatable_roles_oss.rs b/backend/windmill-common/src/datatable_roles_oss.rs index 33d711e7f9..739f08d60b 100644 --- a/backend/windmill-common/src/datatable_roles_oss.rs +++ b/backend/windmill-common/src/datatable_roles_oss.rs @@ -29,7 +29,8 @@ pub(crate) use crate::datatable_roles_ee::{ ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere, insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx, registered_instance_databases, rename_datatable_role, resolve_datatable_role_connection, - role_cluster, set_datatable_role_login, update_role_catalog_entry, + role_cluster, set_datatable_role_login, sweep_unused_role_set_logins, + update_role_catalog_entry, }; #[cfg(not(all(feature = "private", feature = "enterprise")))] @@ -235,6 +236,11 @@ mod ce { } } + /// No roles, so no set login to sweep. + pub(crate) async fn sweep_unused_role_set_logins(_db: &DB) -> Result<()> { + Ok(()) + } + pub(crate) async fn forget_datatable_role_everywhere( _tx: &mut Tx<'_>, _role_id: &str, diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 1a314d34c0..75670fe874 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -474,6 +474,8 @@ pub struct RunInlinePreviewScriptFnParams { pub created_by: String, pub permissioned_as: String, pub permissioned_as_email: String, + /// The caller's recorded end user: `job_perms.end_user_email` of the calling job. + pub end_user_email: Option, pub base_internal_url: String, pub worker_name: String, pub conn: crate::worker::Connection, @@ -496,6 +498,8 @@ pub struct RunInlineScriptFnParams { pub created_by: String, pub permissioned_as: String, pub permissioned_as_email: String, + /// The caller's recorded end user: `job_perms.end_user_email` of the calling job. + pub end_user_email: Option, pub base_internal_url: String, pub worker_name: String, pub conn: crate::worker::Connection, diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index a02bd7f44e..3653a224f1 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1564,7 +1564,8 @@ pub struct DataTableReference { /// The access decision for one data table: which role a caller gets, and who may ask for each. #[derive(Deserialize, Serialize, Debug, Clone, Default)] pub struct DataTablePermissions { - /// A role id from the instance catalog, or `admin`. Absent = `admin`. + /// A role id from the instance catalog, `admin`, or + /// [`crate::datatable_roles::NO_DEFAULT_DATATABLE_ROLE`]. Absent = `admin`. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_role: Option, /// Keyed by instance role id, plus the reserved `admin` key. A role absent from this map @@ -2161,9 +2162,13 @@ pub enum DatatableAccess<'a> { PermissionedAs { permissioned_as: &'a str, email: &'a str, + /// The job's viewer: MUST be what the server recorded in `job_perms.end_user_email`, + /// never an argument or anything else the job or its caller can set. + end_user_email: Option<&'a str>, }, - /// A job identified by id; its owner is read from `v2_job`. For agent workers and anything - /// else that authenticates as infrastructure rather than as the job's user. + /// A job identified by id; its owner is read from `v2_job` and its viewer from `job_perms`. + /// For agent workers and anything else that authenticates as infrastructure rather than as + /// the job's user. Job(uuid::Uuid), /// No identity established. Unpermissioned data tables resolve as before; permissioned ones /// are refused, so a caller predating this feature fails closed. @@ -2211,6 +2216,13 @@ pub async fn can_use_datatable_role_in_governing_workspace( /// /// The resolved role **logs in as itself**. Never `SET ROLE`: a script could `RESET ROLE` its way /// back to admin. +/// +/// A default role other than `admin` is a floor rather than the whole connection: a resolution +/// naming no role holds it when the job's own identity may use it, plus the roles whose tenant +/// lists name the job's viewer, as one login that is a member of each. With no role at all it is +/// refused. Being an admin adds none of the viewer's and `admin` is never among them, so a viewer +/// cannot be handed the connection that owns the tables and bypasses row-level security. A named +/// role is that role alone. pub async fn get_datatable_resource_from_db( db: &DB, w_id: &str, diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 6f6c48baec..6088fb4960 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -1726,6 +1726,23 @@ pub async fn cached_result_path( hasher.update(b"wac_step:"); hasher.update(step_key.as_bytes()); } + // What a run returns can depend on who it ran for (`WM_END_USER_EMAIL`, a data table used + // as the viewer), so a result cached for one viewer is never served to another. Read from + // `job_perms`: a flow job re-fetched between steps does not carry it. + let end_user: Option> = sqlx::query_scalar( + "SELECT end_user_email FROM job_perms WHERE job_id = $1 AND workspace_id = $2", + ) + .bind(job.id) + .bind(&job.workspace_id) + .fetch_optional(db) + .await?; + // Length-prefixed: the arguments follow undelimited, and the end of one viewer's address + // must not read as the start of another run's first argument name. + if let Some(end_user) = end_user.flatten() { + hasher.update(b"end_user:"); + hasher.update((end_user.len() as u64).to_le_bytes()); + hasher.update(end_user.as_bytes()); + } hash_args( db, client, @@ -2475,6 +2492,52 @@ mod tests { } } + // A cache hit returns before the job runs, so the key is all that keeps a result computed + // for one viewer (a data table used as the viewer, `WM_END_USER_EMAIL`) from another. + #[sqlx::test(migrations = "../migrations")] + async fn a_cached_result_is_keyed_by_the_recorded_viewer(pool: sqlx::PgPool) { + let client = test_client(); + + // The last two differ only in where the viewer's address ends and the first argument + // name starts. + let mut paths = vec![]; + for (viewer, arg) in [ + (Some("a@windmill.dev"), "mode"), + (Some("a@windmill.dev"), "mode"), + (Some("b@windmill.dev"), "mode"), + (None, "mode"), + (Some("a@windmill.co"), "mode"), + (Some("a@windmill.com"), "ode"), + ] { + let args = HashMap::from([(arg.to_string(), to_raw_value(&"all"))]); + let job = MiniPulledJob { + id: uuid::Uuid::new_v4(), + args: Some(sqlx::types::Json(args)), + ..test_job() + }; + sqlx::query( + "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, + workspace_id, groups, folders, end_user_email) + VALUES ($1, 'publisher@windmill.dev', 'publisher', false, false, 'test', '{}', + '{}', $2)", + ) + .bind(job.id) + .bind(viewer) + .execute(&pool) + .await + .unwrap(); + paths.push(cached_result_path(&pool, &client, &job, None).await.unwrap()); + } + assert_eq!( + paths[0], paths[1], + "one viewer shares their own cached result" + ); + assert_ne!(paths[0], paths[2], "two viewers share a cached result"); + assert_ne!(paths[0], paths[3]); + assert_ne!(paths[2], paths[3]); + assert_ne!(paths[4], paths[5], "an address runs into the argument names"); + } + #[tokio::test] async fn test_transform_array_over_1000_passthrough() { let db_url = std::env::var("DATABASE_URL") diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index b8c850a2f4..58e47b9332 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -2758,6 +2758,7 @@ async fn transform_attach_datatable( DatatableAccess::PermissionedAs { permissioned_as: &job.permissioned_as, email: &job.permissioned_as_email, + end_user_email: job.permissioned_as_end_user_email.as_deref(), }, ) .await? diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index aac7d549db..6309ba8c95 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -1229,6 +1229,7 @@ pub async fn do_postgresql( DatatableAccess::PermissionedAs { permissioned_as: &job.permissioned_as, email: &job.permissioned_as_email, + end_user_email: job.permissioned_as_end_user_email.as_deref(), }, ) .await? diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 7c3c3971fa..3aa377c904 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -7253,7 +7253,7 @@ pub fn init_worker_internal_server_inline_utils( base_internal_url, killpill_rx: Arc::new(killpill_rx), run_inline_preview_script: Arc::new(|params| { - let job = MiniPulledJob::new_inline( + let mut job = MiniPulledJob::new_inline( params.workspace_id, params.args, params.created_by, @@ -7265,6 +7265,7 @@ pub fn init_worker_internal_server_inline_utils( "inline_preview".to_string(), Some(params.lang), ); + job.permissioned_as_end_user_email = params.end_user_email; Box::pin(async move { let mut mem_peak: i32 = -1; let mut canceled_by: Option = None; @@ -7314,7 +7315,7 @@ pub fn init_worker_internal_server_inline_utils( let content_info = get_script_content_by_hash(&script_hash, ¶ms.workspace_id, ¶ms.conn) .await?; - let job = MiniPulledJob::new_inline( + let mut job = MiniPulledJob::new_inline( params.workspace_id, params.args, params.created_by, @@ -7326,6 +7327,7 @@ pub fn init_worker_internal_server_inline_utils( "inline_run".to_string(), content_info.language, ); + job.permissioned_as_end_user_email = params.end_user_email; let mut mem_peak: i32 = -1; let mut canceled_by: Option = None; let mut column_order: Option> = None; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index ab4ef31c3e..d90a401260 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6557,7 +6557,7 @@ data: - \`/
\` — Specific table in public schema - \`/:
\` — Table in specific schema -**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role. +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of whoever is using the app unless that default is \`admin\`. ## SQL Migrations (sql_to_apply/) @@ -6863,6 +6863,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer 3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable. 4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables. 5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`. +6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as \`pg_has_role(current_user, '', 'member')\` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no \`data.roles\` entry. \`wmill datatable list\` lists the workspace's data tables. Create or change tables with a migration in \`sql_to_apply/\` (see "SQL Migrations" above), then add them to \`data.tables\` in \`raw_app.yaml\`. diff --git a/docs/datatable-viewer-roles.md b/docs/datatable-viewer-roles.md new file mode 100644 index 0000000000..9855e4f97a --- /dev/null +++ b/docs/datatable-viewer-roles.md @@ -0,0 +1,93 @@ +# Data table connections and the viewer's roles + +A data table under roles (Enterprise Edition) resolves a connection that names a role to that +role alone. A connection that names none is resolved from the data table's default role: + +| Default role | A connection naming no role holds | +|---|---| +| `admin` (until changed) | `admin`, as before viewer roles existed | +| another role | that role when the job's own identity may use it, plus **every role the job's viewer is a tenant of** | +| none (`NO_DEFAULT_DATATABLE_ROLE`) | the viewer's roles only | + +A connection left with no role at all is refused. Postgres row-level security can then answer +per viewer from a backend runnable that runs as the app's publisher, with nothing written in the +runnable. + +Policies test membership, which is true for a role's own login too, so one policy serves a named +connection and an unnamed one: + +```sql +CREATE POLICY emea ON orders FOR SELECT + USING (region = 'emea' AND pg_has_role(current_user, 'sales_emea', 'member')); +``` + +A policy names its role as text, so renaming the role breaks it. + +## Code + +| Where | What | +|---|---| +| `windmill-common/src/workspaces.rs` | `DatatableAccess`, `get_datatable_resource_from_db` | +| `windmill-common/src/datatable_roles_ee.rs` | `resolve_datatable_role_connection`, `viewer_role_ids`, `ensure_role_set_login`, `retire_role_set_logins_of`, `sweep_unused_role_set_logins` | +| `windmill-api-jobs/src/execution.rs` | `run_end_user_email`, where a run's viewer is recorded | +| `frontend/.../workspaceSettings/DataTablePermissionsButton.svelte` | the roles drawer, which warns while the default is `admin` | + +## What holds it together + +- **A default of `admin` is not a floor.** `admin` owns the tables and bypasses row-level + security, so nothing could be added to it, and it is the default until someone changes it. + Such a data table resolves exactly as it did before viewer roles existed. Nothing ever sets a + default back to `admin`: removing or deleting the default role leaves none. +- **The default role is tenant-checked against the job's own identity**, the publisher for a + publisher-mode app, and held only when that passes. The viewer's roles are added either way. +- **No role at all is a refusal, never an empty answer.** There is no login that would read + nothing: what a role sees is up to each table's grants and policies. A job without a recorded + viewer (anonymous app viewer, schedule, trigger, a tool job started by an AI agent step), or + whose viewer is not a member of the governing workspace, therefore needs a default it may use + or a named role. +- **A set login owns what it creates.** Postgres gives a new object to `current_user`, and + default privileges go by the creating role, so a table created over a connection that holds + several roles belongs to that set's login: no other role or viewer reaches it, and it passes to + `custom_instance_user` when the login is swept. Unnamed connections are for rows. To create an + object, name a role or run `SET ROLE ` first, which a set login may do for any role it + holds. Migrations are not affected: one that declares no role runs as `admin`. +- **The viewer is `job_perms.end_user_email`, and nothing else.** The server records it when the + run starts: the session behind an app request, the caller of a direct run or preview, and for a + job started with another job's token, that job's viewer. No argument reaches it. +- **A cached result belongs to one viewer.** `cached_result_path` hashes the run's recorded viewer + into the key, since a cache hit returns before any connection is resolved. Runs with no viewer + recorded keep sharing one entry. +- **Only a tenant list naming the viewer counts.** A workspace admin may *use* every role, + `admin` included. The viewer's roles are those whose tenants name them (user, group, folder, + `*`), and never `admin`. +- **One role is that role's login. Several are a login per distinct set.** `wm_set_` is a + member of exactly those roles, with `INHERIT`; the hash is over their catalog ids, so a rename + moves nothing and a change in someone's groups resolves them to another login instead of + editing one. Its privileges are its memberships, so `SET ROLE` only narrows and `RESET ROLE` + comes back to it. The prefix is reserved in `validate_role_name`. +- **Resolving stays cheap where no viewer could add anything.** The viewer is only looked up when + some role other than the default and `admin` has tenants on that data table. +- **A `datatable_role_set` row with a password names a login that can authenticate with it.** The + row is written after the login exists and removed, in its own commit, before the login is + dropped or made `NOLOGIN`. A login without a row is taken back with a new password on next use, + and so is one whose row has an empty password: that is what a sweep leaves when it could + neither drop a login nor let it back in, so the next sweep still finds it. +- **Set logins are bounded by use, not by the number of role combinations.** One exists only for + a set some viewer actually connected with, so there are at most as many as (viewer, data table) + pairs. `last_used_at` is recorded at most once a day, and `sweep_unused_role_set_logins`, run + from the monitor loop, drops those unused for 30 days; a dropped one is made again on demand. + The sweep goes by this instance's rows, never by the cluster's `wm_set_*` roles: a cluster can + serve several Windmill instances. +- **Disabling or dropping a role retires every set login holding it.** `NOLOGIN` on a role does + not reach a login that is a member of it. +- **A worker or server that predates this connects as the default role alone**, so a mixed fleet + shows a viewer fewer rows, never more. It refuses a data table with no default role, whose + marker it reads as a role the data table lacks. + +## Trust + +A job that names no role runs its author's code with the viewer's reach into the data table, the +same trust as an app in viewer execution mode or a script someone else wrote and you run. Whoever +can deploy a publisher-mode app, or a script that others run, can therefore read through the +non-`admin` roles of the people who use it. Naming a role in the code is how an author pins the +connection to one role instead. diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 837569c575..5b34c66bc5 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -45,7 +45,12 @@ import { favoriteManager } from './sidebar/FavoriteMenu.svelte' import DatatableRoleBadge from './DatatableRoleBadge.svelte' import type { AclTarget, Asset, DataTableTables } from '$lib/gen' - import { ADMIN_DATATABLE_ROLE, datatableNameTakesRole, type DatatableRowAction } from './dbTypes' + import { + ADMIN_DATATABLE_ROLE, + datatableNameTakesRole, + namedDefaultRole, + type DatatableRowAction + } from './dbTypes' import TextInput from './text_input/TextInput.svelte' import Checkbox from './common/checkbox/Checkbox.svelte' import DbSchemaDiagram from './dbdiagram/DbSchemaDiagram.svelte' @@ -417,7 +422,10 @@ if (!entry?.permissioned || !datatableNameTakesRole(datatable)) return undefined const roles = entry.usable_roles if (roles.length === 0) return { kind: 'none' } - const role = (datatable === currentDatatable ? currentRole : undefined) ?? entry.default_role + const role = + (datatable === currentDatatable ? currentRole : undefined) ?? + namedDefaultRole(entry.default_role) ?? + roles[0] if (roles.length === 1 && roles[0] === ADMIN_DATATABLE_ROLE && role === ADMIN_DATATABLE_ROLE) { return undefined } diff --git a/frontend/src/lib/components/dbTypes.ts b/frontend/src/lib/components/dbTypes.ts index e6d3af12e8..2bb2a92e14 100644 --- a/frontend/src/lib/components/dbTypes.ts +++ b/frontend/src/lib/components/dbTypes.ts @@ -32,6 +32,9 @@ export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includ /** The role every data table has: the one it connects as when it is not under roles. */ export const ADMIN_DATATABLE_ROLE = 'admin' +/** What `default_role` holds for a data table with no default role. Mirrors the backend's + * `NO_DEFAULT_DATATABLE_ROLE`. */ +export const NO_DEFAULT_DATATABLE_ROLE = '$none' /** What the server accepts in `-- role ` and `?role=`. */ export function isDatatableRoleName(name: string): boolean { @@ -52,7 +55,13 @@ export function defaultMigrationRole( permissioned: boolean | undefined, defaultRole: string | undefined ): string | undefined { - return permissioned && !datatableNameTakesRole(name) ? defaultRole : undefined + return permissioned && !datatableNameTakesRole(name) ? namedDefaultRole(defaultRole) : undefined +} + +/** A data table's default role, or `undefined` when it has none: the server reports that as + * `NO_DEFAULT_DATATABLE_ROLE`, which is not a role anything can connect as. */ +export function namedDefaultRole(defaultRole: string | undefined): string | undefined { + return defaultRole === NO_DEFAULT_DATATABLE_ROLE ? undefined : defaultRole } /** `datatable://`, with `?role=` when a role is named. Throws rather than build a diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte index af0af7ed5b..0ebb67094b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte @@ -14,6 +14,7 @@ ADMIN_DATATABLE_ROLE, datatableNameTakesRole, defaultMigrationRole, + namedDefaultRole, type DbInput } from '../dbTypes' import type { PendingRowAction, SelectedTable } from '../DBManager.svelte' @@ -173,15 +174,15 @@ return rolesOfCurrent?.permissioned ? selectedRole : undefined } const entry = datatableTree.current.find((t) => t.datatable_name === datatable) - return entry?.permissioned ? entry.default_role : undefined + return entry?.permissioned ? namedDefaultRole(entry.default_role) : undefined } function defaultRoleOf(datatable: string): string | undefined { if (datatable === selectedDatatable && rolesOfCurrent?.permissioned) { - return rolesOfCurrent.default_role + return namedDefaultRole(rolesOfCurrent.default_role) } const entry = datatableTree.current.find((t) => t.datatable_name === datatable) - return entry?.permissioned ? entry.default_role : undefined + return entry?.permissioned ? namedDefaultRole(entry.default_role) : undefined } const tableDatatable = (t: SelectedTable) => t.datatable ?? selectedDatatable diff --git a/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte index 680421450d..496c967d7f 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte @@ -23,7 +23,7 @@ import { superadmin } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { deepEqual } from 'fast-equals' - import { ADMIN_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes' + import { ADMIN_DATATABLE_ROLE, NO_DEFAULT_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes' import PgAclEditor from '../datatableAcl/PgAclEditor.svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' @@ -87,6 +87,8 @@ const pgInstanceName = $derived(pgInstanceNameOf(info?.cluster)) const unusedRoles = $derived(availableRoles.filter((r) => !roles.some((row) => row.id === r.id))) const confirmationModal = createAsyncConfirmationModal() + // Every member of the governing workspace, as the tenant lists spell it. + const EVERYONE_TENANT = '*' let aclEditor = $state(undefined) const hasUnsavedChanges = $derived( @@ -111,9 +113,13 @@ (a, b) => Number(b.id === ADMIN_DATATABLE_ROLE) - Number(a.id === ADMIN_DATATABLE_ROLE) ) // A data table never put under roles comes back with none; admin is what turning the toggle - // on starts from. + // on starts from, open to everyone so that turning it on changes nothing by itself. if (!loaded.some((r) => r.id === ADMIN_DATATABLE_ROLE)) { - loaded.unshift({ id: ADMIN_DATATABLE_ROLE, name: ADMIN_DATATABLE_ROLE, tenants: [] }) + loaded.unshift({ + id: ADMIN_DATATABLE_ROLE, + name: ADMIN_DATATABLE_ROLE, + tenants: [EVERYONE_TENANT] + }) } info = res catalog = undefined @@ -137,7 +143,7 @@ FolderService.listFolderNames({ workspace: ws }) ]) tenantItems = [ - { value: '*', label: 'Everyone', group: 'Anyone in the workspace' }, + { value: EVERYONE_TENANT, label: 'Everyone', group: 'Anyone in the workspace' }, ...users.map((u) => ({ value: `u/${u}`, label: u, group: 'Users' })), ...groups.map((g) => ({ value: `g/${g}`, label: g, group: 'Groups' })), ...folders.map((f) => ({ value: `f/${f}`, label: f, group: 'Folders' })) @@ -218,11 +224,28 @@ } } - function removeRole(role: EditedRole) { + async function removeRole(role: EditedRole) { + if (defaultRoleId === role.id) { + const confirmed = await confirmationModal.ask({ + title: 'Remove the default role?', + children: `Jobs that name no role connect as ${role.name ?? role.id}. Without it the data table has no default role: they connect with the roles of the person running them, and are refused when that person has none.`, + confirmationText: 'Remove', + type: 'danger' + }) + if (!confirmed) return + defaultRoleId = NO_DEFAULT_DATATABLE_ROLE + } roles = roles.filter((r) => r.id !== role.id) - if (defaultRoleId === role.id) defaultRoleId = ADMIN_DATATABLE_ROLE } + const defaultRoleName = $derived(roles.find((r) => r.id === defaultRoleId)?.name ?? defaultRoleId) + const defaultsToAdmin = $derived(defaultRoleId === ADMIN_DATATABLE_ROLE) + const hasNoDefault = $derived(defaultRoleId === NO_DEFAULT_DATATABLE_ROLE) + const hasOtherRoles = $derived(roles.some((r) => r.id !== ADMIN_DATATABLE_ROLE)) + const adminOpenToEveryone = $derived( + !!roles.find((r) => r.id === ADMIN_DATATABLE_ROLE)?.tenants.includes(EVERYONE_TENANT) + ) + async function save() { saving = true // Turning roles off clears the whole block, so every role it named is removed. @@ -320,7 +343,7 @@ options={{ right: 'Put this data table under roles', rightTooltip: - 'Off, every job connects as admin — the connection that owns every table. On, every job resolves to a role, and a caller no tenant covers is refused.' + 'Off, every job connects as admin — the connection that owns every table. On, every job resolves to a role, and a caller no tenant covers is refused. It starts with admin open to everyone, so turning it on changes nothing until the roles are edited.' }} /> @@ -391,8 +414,10 @@ Default - The role a job gets when it names none — no `-- role` annotation, no `?role=` in - the reference. Callers still have to be one of its tenants. + The role a job gets when it names none: no `-- role` annotation, no `?role=` in + the reference. There is always one choice: admin until changed, another role, or + none. Unless it is admin, the roles the person running the job is a tenant of + are added to it. @@ -440,6 +465,29 @@ {/each} + + + No default role + + + + Jobs that name no role get only the roles of the person running them. + + + +
+ { + defaultRoleId = NO_DEFAULT_DATATABLE_ROLE + }} + /> +
+
+ +
{#if editable} @@ -466,6 +514,47 @@ {/if} + + {#if adminOpenToEveryone} + + Every member of the workspace may use admin{defaultsToAdmin + ? ', and a job that names no role connects as it, exactly as with roles off' + : ' by naming it'}. admin owns every table: it reads every row and ignores grants + and row-level security. Once the other roles are set up, remove Everyone from admin's + tenants{defaultsToAdmin ? ' and choose another default, or none' : ''}. + + {/if} + {#if defaultsToAdmin && hasOtherRoles && !adminOpenToEveryone} + + A job that names no role connects as admin when it runs as a workspace admin or as one + of admin's tenants, which covers every viewer of an app an admin published. admin owns + every table: it reads every row and ignores grants and row-level security. Everyone + else's jobs that name no role are refused. Choose another default, or none, to have + the roles above apply to them. + + {:else if !defaultsToAdmin} +
+ {#if hasNoDefault} + + A job that names no role connects with the roles here that the person running it + is a tenant of. It is refused when they have none, and so is a job with nobody + behind it: a schedule, a trigger, an anonymous app viewer. + + {:else} + + A job that names no role connects as + {defaultRoleName} when it may use it, plus every other + role here that the person running it is a tenant of. With neither, it is refused. + + {/if} + A job that names a role connects as that role alone. + + A table created over a connection that holds several roles belongs to that + combination of roles alone. Run SET ROLE to one role before + creating it, or name a role. + +
+ {/if} {/if} diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 097a7af895..c3310351b7 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1131,6 +1131,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer 3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable. 4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables. 5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`. +6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as \`pg_has_role(current_user, '', 'member')\` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no \`data.roles\` entry. ### Querying in TypeScript (Bun/Deno) diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 7afcbd2969..bd21352995 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -188,7 +188,7 @@ data: - `/
` — Specific table in public schema - `/:
` — Table in specific schema -**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role. +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of whoever is using the app unless that default is `admin`. ## SQL Migrations (sql_to_apply/) @@ -494,6 +494,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer 3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable. 4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables. 5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`. +6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as `pg_has_role(current_user, '', 'member')` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no `data.roles` entry. `wmill datatable list` lists the workspace's data tables. Create or change tables with a migration in `sql_to_apply/` (see "SQL Migrations" above), then add them to `data.tables` in `raw_app.yaml`. diff --git a/system_prompts/base/raw-app-cli.md b/system_prompts/base/raw-app-cli.md index f12762ff5f..e22de102f6 100644 --- a/system_prompts/base/raw-app-cli.md +++ b/system_prompts/base/raw-app-cli.md @@ -183,7 +183,7 @@ data: - `/
` — Specific table in public schema - `/:
` — Table in specific schema -**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role. +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of whoever is using the app unless that default is `admin`. ## SQL Migrations (sql_to_apply/) diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 23b09f1996..0979000b2a 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -264,6 +264,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer 3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable. 4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables. 5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`. +6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as `pg_has_role(current_user, '', 'member')` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no `data.roles` entry. `wmill datatable list` lists the workspace's data tables. Create or change tables with a migration in `sql_to_apply/` (see "SQL Migrations" above), then add them to `data.tables` in `raw_app.yaml`.