From ae093b7666cefc3777be0e45144bb272732846d0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 15:56:11 +0200 Subject: [PATCH 01/71] 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 02/71] 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 03/71] 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 04/71] 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 05/71] 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 16/71] 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 17/71] 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 18/71] 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 19/71] 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 20/71] 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 21/71] 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 22/71] 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 23/71] 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 24/71] 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 25/71] 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 26/71] 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 27/71] 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 28/71] 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 29/71] 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 30/71] 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 31/71] 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 32/71] 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 43/71] 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 44/71] 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 45/71] 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 46/71] 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 47/71] 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 48/71] 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 49/71] 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 50/71] 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 51/71] 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 52/71] 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 53/71] 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 54/71] 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 55/71] 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 56/71] 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 57/71] 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 58/71] 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 59/71] 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 60/71] 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 61/71] 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} +