From 1c068f33e144af81f202e21e23960af4a17a6b4a Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 14 Oct 2026 10:14:46 +0200 Subject: [PATCH 01/49] fix(worker): keep script env vars off the nsjail launcher process (#11749) * fix(worker): keep script env vars off the nsjail launcher process Co-Authored-By: Claude Opus 5.5 * fix(worker): keep script env off the ruby gem-install nsjail launcher Co-Authored-By: Claude Opus 5.5 * fix(worker): keep script env off the unjailed javac compile step Co-Authored-By: Claude Opus 5.5 * fix(worker): gate script_overrides on the executors that use it Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../nsjail/download.ruby.config.proto | 4 + .../nsjail/run.bun.config.proto | 3 + .../nsjail/run.csharp.config.proto | 3 + .../nsjail/run.go.config.proto | 3 + .../nsjail/run.java.config.proto | 3 + .../nsjail/run.nu.config.proto | 3 + .../nsjail/run.php.config.proto | 3 + .../windmill-worker/nsjail/run.r.config.proto | 3 + .../nsjail/run.ruby.config.proto | 3 + .../nsjail/run.rust.config.proto | 3 + backend/windmill-worker/src/bun_executor.rs | 64 ++++---- backend/windmill-worker/src/common.rs | 150 ++++++++++++++++++ .../windmill-worker/src/csharp_executor.rs | 38 ++--- backend/windmill-worker/src/dbt_executor.rs | 22 +-- backend/windmill-worker/src/docker_v2.rs | 55 +------ backend/windmill-worker/src/go_executor.rs | 36 ++--- backend/windmill-worker/src/java_executor.rs | 58 ++++--- backend/windmill-worker/src/nu_executor.rs | 45 +++--- backend/windmill-worker/src/php_executor.rs | 31 ++-- backend/windmill-worker/src/r_executor.rs | 43 ++--- backend/windmill-worker/src/ruby_executor.rs | 98 +++++++----- backend/windmill-worker/src/rust_executor.rs | 39 ++--- 22 files changed, 444 insertions(+), 266 deletions(-) diff --git a/backend/windmill-worker/nsjail/download.ruby.config.proto b/backend/windmill-worker/nsjail/download.ruby.config.proto index 4d6b398fda..31eefae766 100644 --- a/backend/windmill-worker/nsjail/download.ruby.config.proto +++ b/backend/windmill-worker/nsjail/download.ruby.config.proto @@ -10,6 +10,10 @@ rlimit_cpu: 1000 rlimit_fsize: 1024 rlimit_nofile: 64 +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} + envar: "HOME=/user" envar: "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH" diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index 43e4464beb..cd1510adc6 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -20,6 +20,9 @@ clone_newcgroup: false skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount { src: "/bin" diff --git a/backend/windmill-worker/nsjail/run.csharp.config.proto b/backend/windmill-worker/nsjail/run.csharp.config.proto index c624e55c43..cf2d4f7f0e 100644 --- a/backend/windmill-worker/nsjail/run.csharp.config.proto +++ b/backend/windmill-worker/nsjail/run.csharp.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.go.config.proto b/backend/windmill-worker/nsjail/run.go.config.proto index 1e6de06c9c..691707b22f 100644 --- a/backend/windmill-worker/nsjail/run.go.config.proto +++ b/backend/windmill-worker/nsjail/run.go.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount { src: "/bin" diff --git a/backend/windmill-worker/nsjail/run.java.config.proto b/backend/windmill-worker/nsjail/run.java.config.proto index 9ae43f5b52..b53758c3a3 100644 --- a/backend/windmill-worker/nsjail/run.java.config.proto +++ b/backend/windmill-worker/nsjail/run.java.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.nu.config.proto b/backend/windmill-worker/nsjail/run.nu.config.proto index 6c517a5b39..91174f920c 100644 --- a/backend/windmill-worker/nsjail/run.nu.config.proto +++ b/backend/windmill-worker/nsjail/run.nu.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.php.config.proto b/backend/windmill-worker/nsjail/run.php.config.proto index 6910cbe1a0..3156d6a71c 100644 --- a/backend/windmill-worker/nsjail/run.php.config.proto +++ b/backend/windmill-worker/nsjail/run.php.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.r.config.proto b/backend/windmill-worker/nsjail/run.r.config.proto index bc4d74bcba..a0c4c51cf0 100644 --- a/backend/windmill-worker/nsjail/run.r.config.proto +++ b/backend/windmill-worker/nsjail/run.r.config.proto @@ -14,6 +14,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} # mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.ruby.config.proto b/backend/windmill-worker/nsjail/run.ruby.config.proto index 2527e785b6..f1a642e0b8 100644 --- a/backend/windmill-worker/nsjail/run.ruby.config.proto +++ b/backend/windmill-worker/nsjail/run.ruby.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} # mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.rust.config.proto b/backend/windmill-worker/nsjail/run.rust.config.proto index 070a76e997..2b60847f98 100644 --- a/backend/windmill-worker/nsjail/run.rust.config.proto +++ b/backend/windmill-worker/nsjail/run.rust.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount { src: "/bin" diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index de33a2b9e6..8e657d1226 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -21,8 +21,8 @@ use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, write_file_binary, MaybeLock, - OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, + resolve_nsjail_tmp_mount_block, script_envars, start_child_process, write_file_binary, + MaybeLock, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -2474,35 +2474,6 @@ try {{ let child = if is_sandboxing_enabled() || annotation.sandbox { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - let _ = write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_BUN_CONTENT - .replace("{LANG}", if annotation.nodejs { "nodejs" } else { "bun" }) - .replace("{JOB_DIR}", job_dir) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{UIDGIDMAP}", if *DISABLE_NUSER { "" } else { - "uidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}\n\ngidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}" - }) - .replace( - "{SHARED_MOUNT}", - &shared_mount.replace( - "/tmp/shared", - if annotation.nodejs { - "/tmp/nodejs/shared" - } else { - "/tmp/bun/shared" - }, - ), - ) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); let args = if annotation.nodejs { @@ -2541,7 +2512,6 @@ try {{ nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs( get_proxy_envs_for_lang( @@ -2558,6 +2528,36 @@ try {{ .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + let _ = write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_BUN_CONTENT + .replace("{LANG}", if annotation.nodejs { "nodejs" } else { "bun" }) + .replace("{JOB_DIR}", job_dir) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{UIDGIDMAP}", if *DISABLE_NUSER { "" } else { + "uidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}\n\ngidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}" + }) + .replace( + "{SHARED_MOUNT}", + &shared_mount.replace( + "/tmp/shared", + if annotation.nodejs { + "/tmp/nodejs/shared" + } else { + "/tmp/bun/shared" + }, + ), + ) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { let cmd = if annotation.nodejs { diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 50af562062..6cac284f21 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -1314,6 +1314,84 @@ pub fn render_nsjail_rlimit_as(env_override: Option<&str>, default_mb: u32) -> S } } +/// Quote a string as a protobuf-text-format string literal for safe inclusion in +/// an nsjail config. Every byte is emitted as a printable ASCII char or a valid +/// protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit octal `\NNN` for +/// control/non-ASCII bytes), so a hostile value can neither break the literal nor +/// inject further directives. +/// +/// `{` and `}` are escaped too: profiles are rendered by substituting `{PLACEHOLDER}` +/// tokens one after another, so a value that still contained braces could smuggle in a +/// later placeholder's expansion — and `{ENVARS}` expands to unquoted directives. +pub(crate) fn proto_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for &b in s.as_bytes() { + match b { + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + b'{' | b'}' => out.push_str(&format!("\\{b:03o}")), + 0x20..=0x7e => out.push(b as char), + _ => out.push_str(&format!("\\{b:03o}")), + } + } + out.push('"'); + out +} + +/// Render env vars as nsjail `envar:` directives (one per line), the only way +/// caller-controlled env may reach a jailed child. Set on the process that execs +/// nsjail instead, `LD_PRELOAD`/`LD_LIBRARY_PATH`/`LD_AUDIT` would be honoured by +/// the loader for nsjail itself, before any namespace or chroot exists. +pub(crate) fn render_envars<'a>(env: impl IntoIterator) -> String { + env.into_iter() + .map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}")))) + .collect::>() + .join("\n") +} + +/// The script's env vars as `envar:` directives for its nsjail run profile. +/// +/// The profile sets `keep_env`, so the child also inherits the launcher's env, and +/// an `envar:` overrides an inherited key. A key the launcher sets therefore keeps +/// the launcher's value, as it did when both were set on the launcher with the +/// script's env first, unless it is listed in `script_overrides` (see +/// [`script_overrides`]). +pub(crate) fn script_envars( + envs: &HashMap, + launcher: &Command, + script_overrides: &[&str], +) -> String { + let launcher_keys: HashSet<&std::ffi::OsStr> = + launcher.as_std().get_envs().map(|(k, _)| k).collect(); + render_envars( + envs.iter() + .filter(|(k, _)| { + script_overrides.contains(&k.as_str()) + || !launcher_keys.contains(std::ffi::OsStr::new(k.as_str())) + }) + .map(|(k, v)| (k.as_str(), v.as_str())), + ) +} + +/// The keys an executor sets on its nsjail launcher before the script's env, so the +/// script's value wins, less those `reserved_variables` (applied after the script's env, +/// and carrying worker-group env vars) sets again, so the worker's value wins. +#[cfg(any(feature = "nu", feature = "java", feature = "ruby", feature = "rlang"))] +pub(crate) fn script_overrides<'a>( + set_before: &[&'a str], + reserved_variables: &HashMap, +) -> Vec<&'a str> { + set_before + .iter() + .copied() + .filter(|k| !reserved_variables.contains_key(*k)) + .collect() +} + /// Default size (in bytes) of the `/tmp` tmpfs mount inside nsjail sandboxes, /// used when the `nsjail_tmpfs_size_mb` instance setting is unset. pub const DEFAULT_NSJAIL_TMPFS_SIZE_BYTES: u64 = 800_000_000; @@ -2636,6 +2714,78 @@ mod tests { assert!(result.is_err()); } + + // A profile that loses `{ENVARS}` silently drops the script's env, since a + // `replace` on a missing token does nothing. It must also precede every fixed + // `envar:`, which overrides it the way it overrode the launcher's env. + #[test] + fn script_profiles_take_envars_before_their_fixed_ones() { + for (name, profile) in [ + ("go", include_str!("../nsjail/run.go.config.proto")), + ("rust", include_str!("../nsjail/run.rust.config.proto")), + ("bun", include_str!("../nsjail/run.bun.config.proto")), + ("nu", include_str!("../nsjail/run.nu.config.proto")), + ("php", include_str!("../nsjail/run.php.config.proto")), + ("csharp", include_str!("../nsjail/run.csharp.config.proto")), + ("java", include_str!("../nsjail/run.java.config.proto")), + ("ruby", include_str!("../nsjail/run.ruby.config.proto")), + ("r", include_str!("../nsjail/run.r.config.proto")), + ( + "gem install", + include_str!("../nsjail/download.ruby.config.proto"), + ), + ] { + let at = profile + .find("\n{ENVARS}\n") + .unwrap_or_else(|| panic!("{name}: no {{ENVARS}}")); + assert!( + !profile[..at].contains("\nenvar:"), + "{name}: a fixed envar precedes {{ENVARS}}" + ); + } + } + + #[test] + fn script_envars_reach_the_jail_not_the_launcher() { + let envs = HashMap::from([ + ("LD_PRELOAD".to_string(), "/tmp/evil.so".to_string()), + ("WM_TOKEN".to_string(), "forged".to_string()), + ("PATH".to_string(), "/custom/bin".to_string()), + ]); + let mut launcher = Command::new("nsjail"); + launcher + .env_clear() + .env("PATH", "/usr/bin") + .env("WM_TOKEN", "real"); + + let out = script_envars(&envs, &launcher, &["PATH"]); + let mut lines = out.lines().collect::>(); + lines.sort(); + // A launcher key keeps the launcher's value unless the executor lets the + // script override it. + assert_eq!( + lines, + vec![ + "envar: \"LD_PRELOAD=/tmp/evil.so\"", + "envar: \"PATH=/custom/bin\"", + ] + ); + assert!(launcher + .as_std() + .get_envs() + .all(|(k, _)| k != std::ffi::OsStr::new("LD_PRELOAD"))); + + let hostile = HashMap::from([( + "X".to_string(), + "v\"\nclone_newuser: false {JOB_DIR}".to_string(), + )]); + let line = script_envars(&hostile, &launcher, &[]); + assert_eq!(line.lines().count(), 1, "{line}"); + assert!( + !line.contains('{') && !line.contains("\nclone_newuser"), + "{line}" + ); + } } lazy_static::lazy_static! { diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index 7d77f8703f..87213223e7 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -28,7 +28,7 @@ use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, get_reserved_variables, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, DEV_CONF_NSJAIL, + resolve_nsjail_tmp_mount_block, script_envars, start_child_process, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -729,28 +729,10 @@ pub async fn handle_csharp_job( let child = if is_sandboxing_enabled() { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_CSHARP_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", &*CSHARP_CACHE_DIR) - .replace("{CACHE_HASH}", &hash) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{SHARED_MOUNT}", shared_mount) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs( get_proxy_envs_for_lang( @@ -777,6 +759,24 @@ pub async fn handle_csharp_job( #[cfg(windows)] nsjail_cmd.env("SystemRoot", SYSTEM_ROOT.as_str()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_CSHARP_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CACHE_DIR}", &*CSHARP_CACHE_DIR) + .replace("{CACHE_HASH}", &hash) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{SHARED_MOUNT}", shared_mount) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), true).await? } else { #[cfg(unix)] diff --git a/backend/windmill-worker/src/dbt_executor.rs b/backend/windmill-worker/src/dbt_executor.rs index 90db1b9795..dbef02bec3 100644 --- a/backend/windmill-worker/src/dbt_executor.rs +++ b/backend/windmill-worker/src/dbt_executor.rs @@ -31,7 +31,7 @@ use windmill_parser_yaml::{ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::common::{ - render_nsjail_rlimit_as, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, + render_envars, render_nsjail_rlimit_as, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, }; use crate::common::{start_child_process, OccupancyMetrics}; use crate::dbt_engine::{provision_engine, ProvisionedEngine, DBT_CACHE_DIR}; @@ -1393,12 +1393,10 @@ pub(crate) async fn prepare_project( // invocation's. Neither may sit on the launcher. .replace( "{ENVARS}", - &jail_envars( + &render_envars( env.iter() - .map(|(k, v)| (k.clone(), v.clone())) - .chain(invocation_env.iter().map(|(k, v)| (k.clone(), v.clone()))) - .collect::>() - .as_slice(), + .map(|(k, v)| (k.as_str(), v.as_str())) + .chain(invocation_env.iter().map(|(k, v)| (k.as_str(), v.as_str()))), ), ) .replace("{SHARED_MOUNT}", "") @@ -2376,16 +2374,6 @@ pub(crate) fn dbt_command(p: &PreparedProject, args: &[&str]) -> Command { cmd } -/// The project's environment, as jail directives rather than launcher -/// environment. `keep_env` passes nsjail's own environment through, so these -/// are added on top of the Windmill-controlled ones the launcher carries. -fn jail_envars(env: &[(String, String)]) -> String { - env.iter() - .map(|(k, v)| format!("envar: \"{}={}\"", escape_textproto(k), escape_textproto(v))) - .collect::>() - .join("\n") -} - const SANDBOX_PROFILE_NAME: &str = "dbt.nsjail.config.proto"; /// The written nsjail profile, kept OUTSIDE every path the jailed child can @@ -5862,7 +5850,7 @@ mod tests { }; } - let envars = jail_envars(&[("LD_PRELOAD".to_string(), hostile.to_string())]); + let envars = render_envars([("LD_PRELOAD", hostile)]); assert_eq!(envars.lines().count(), 1, "{envars}"); assert!(envars.starts_with("envar: \"LD_PRELOAD="), "{envars}"); } diff --git a/backend/windmill-worker/src/docker_v2.rs b/backend/windmill-worker/src/docker_v2.rs index 52d1060b67..3f47ab6efc 100644 --- a/backend/windmill-worker/src/docker_v2.rs +++ b/backend/windmill-worker/src/docker_v2.rs @@ -32,8 +32,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ - build_args_map, get_reserved_variables, raw_to_string, read_and_check_file, - resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, + build_args_map, get_reserved_variables, proto_str, raw_to_string, read_and_check_file, + render_envars, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, @@ -163,36 +163,6 @@ struct OciConfig { working_dir: Option, } -/// Quote a string as a protobuf-text-format string literal for safe inclusion in -/// the nsjail config. Image-controlled values (mount srcs/dsts, symlink targets, -/// WorkingDir) flow into the config, so they MUST be escaped — an unescaped `"` or -/// newline would otherwise let a hostile image config inject arbitrary nsjail -/// directives and break out of the sandbox. Every byte is emitted as a printable -/// ASCII char or a valid protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit -/// octal `\NNN` for control/non-ASCII bytes), so the result always parses. -/// -/// `{` and `}` are escaped too: the profile is rendered by substituting `{PLACEHOLDER}` -/// tokens one after another, so a value that still contained braces could smuggle in a -/// later placeholder's expansion — and `{ENVARS}` expands to unquoted directives. -fn proto_str(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for &b in s.as_bytes() { - match b { - b'"' => out.push_str("\\\""), - b'\\' => out.push_str("\\\\"), - b'\n' => out.push_str("\\n"), - b'\r' => out.push_str("\\r"), - b'\t' => out.push_str("\\t"), - b'{' | b'}' => out.push_str(&format!("\\{b:03o}")), - 0x20..=0x7e => out.push(b as char), - _ => out.push_str(&format!("\\{b:03o}")), - } - } - out.push('"'); - out -} - /// Why `./result.json` can't be collected, phrased for the job log. const UNVERIFIABLE_DST: &str = "it cannot be proven to resolve inside the container root (a `..` component, or a \ @@ -270,16 +240,6 @@ fn render_result_mount(job_dir: &str, dst: &str) -> String { ) } -/// Render container env vars as nsjail `envar:` directives (one per line). Each -/// `KEY=VALUE` is proto-escaped, so image-controlled keys/values can neither break -/// the config nor reach nsjail's own process environment. -fn render_envars(env: &[(String, String)]) -> String { - env.iter() - .map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}")))) - .collect::>() - .join("\n") -} - /// Run `crane` with the optional per-job `DOCKER_CONFIG` auth dir. async fn crane(args: &[&str], auth_dir: Option<&str>) -> Result { let mut cmd = Command::new(CRANE_PATH.as_str()); @@ -795,7 +755,7 @@ pub async fn handle_docker_v2_job( if !container_env.iter().any(|(k, _)| k == "HOME") { container_env.push(("HOME".to_string(), "/root".to_string())); } - let envars = render_envars(&container_env); + let envars = render_envars(container_env.iter().map(|(k, v)| (k.as_str(), v.as_str()))); // Render the nsjail profile: dynamic per-entry rootfs binds + image WorkingDir. let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; @@ -926,9 +886,10 @@ pub async fn handle_docker_v2_job( #[cfg(test)] mod tests { use super::{ - digest_key, proto_str, ref_key, registry_qualified, render_envars, render_result_mount, - result_mount_dst, SHADOWED_DST, UNVERIFIABLE_DST, + digest_key, ref_key, registry_qualified, render_result_mount, result_mount_dst, + SHADOWED_DST, UNVERIFIABLE_DST, }; + use crate::common::{proto_str, render_envars}; #[tokio::test] async fn result_mount_dst_targets_working_dir() { @@ -1046,14 +1007,14 @@ mod tests { ("PATH".to_string(), "/usr/bin".to_string()), ("LD_PRELOAD".to_string(), "rootfs/evil.so".to_string()), ]; - let out = render_envars(&env); + let out = render_envars(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))); assert_eq!( out, "envar: \"PATH=/usr/bin\"\nenvar: \"LD_PRELOAD=rootfs/evil.so\"" ); // A value trying to inject extra directives is escaped, not interpreted. let evil = vec![("X".to_string(), "v\"\nclone_newuser: false".to_string())]; - let line = render_envars(&evil); + let line = render_envars(evil.iter().map(|(k, v)| (k.as_str(), v.as_str()))); assert!(line.starts_with("envar: \"")); assert!(!line.contains("\nclone_newuser")); assert!(line.contains("\\n")); diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index 9fe6d4a616..fd26484f23 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -22,8 +22,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, script_envars, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, handle_child::handle_child, is_sandboxing_enabled, read_ee_registry, GoBuildLimits, DISABLE_NUSER, GOPRIVATE, GOPROXY, @@ -600,26 +600,10 @@ pub async fn handle_go_job( let child = if is_sandboxing_enabled() { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - let _ = write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_GO_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{SHARED_MOUNT}", shared_mount) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs( get_proxy_envs_for_lang( @@ -637,6 +621,22 @@ pub async fn handle_go_job( .args(vec!["--config", "run.config.proto", "--", "/tmp/go/main"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + let _ = write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_GO_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{SHARED_MOUNT}", shared_mount) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { #[cfg(unix)] diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index 2e47fced18..39c8804cd5 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -28,7 +28,7 @@ use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_result, resolve_job_timeout, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, - start_child_process, OccupancyMetrics, + script_envars, script_overrides, start_child_process, OccupancyMetrics, }, handle_child, is_sandboxing_enabled, read_ee_registry_bool_with_workspace_override, read_ee_registry_with_workspace_override, @@ -678,9 +678,13 @@ async fn compile<'a>( .current_dir(job_dir.to_owned()) .env("PATH", PATH_ENV.as_str()) .env("HOME", &*JAVA_HOME_DIR) - .env("BASE_INTERNAL_URL", base_internal_url) - .envs(envs) - .envs(reserved_variables) + .env("BASE_INTERNAL_URL", base_internal_url); + // javac runs outside the jail even when sandboxing is on: script env + // (`LD_PRELOAD`, `JAVA_TOOL_OPTIONS`) would load code into it on the host. + if !is_sandboxing_enabled() { + cmd.envs(envs); + } + cmd.envs(reserved_variables) .envs(PROXY_ENVS.clone()) .args(&[ "-classpath", @@ -777,29 +781,13 @@ async fn run<'a>( let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_JAVA_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", &*JAVA_CACHE_DIR) - .replace("{SHARED_MOUNT}", &shared_mount) - // .replace("{CACHED_TARGET}", &shared_mount) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() - .current_dir(job_dir) + .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("HOME", &*JAVA_HOME_DIR) .env("BASE_INTERNAL_URL", base_internal_url) - .envs(envs) - .envs(reserved_variables) + .envs(&reserved_variables) .envs(crate::get_otel_context_envs(&job.id)) .args(vec![ "--config", @@ -835,6 +823,32 @@ async fn run<'a>( cmd.args(vec!["-classpath", &classpath, "net.script.App"]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_JAVA_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CACHE_DIR}", &*JAVA_CACHE_DIR) + .replace("{SHARED_MOUNT}", &shared_mount) + // .replace("{CACHED_TARGET}", &shared_mount) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace( + "{ENVARS}", + &script_envars( + &envs, + &cmd, + &script_overrides( + &["PATH", "HOME", "BASE_INTERNAL_URL"], + &reserved_variables, + ), + ), + ), + )?; start_child_process(cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index 8aa0946666..b57225f606 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -14,8 +14,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, script_envars, script_overrides, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, TRACING_PROXY_CA_CERT_PATH, @@ -248,30 +248,13 @@ async fn run<'a>( let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_NU_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{NU_PATH}", &NU_PATH) - .replace("{SHARED_MOUNT}", &shared_mount) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .env_clear() - .current_dir(job_dir) + .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) - .envs(envs) - .envs(reserved_variables) + .envs(&reserved_variables) .envs( get_proxy_envs_for_lang( &ScriptLang::Nu, @@ -293,6 +276,26 @@ async fn run<'a>( .stdout(Stdio::piped()) .stderr(Stdio::piped()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_NU_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{NU_PATH}", &NU_PATH) + .replace("{SHARED_MOUNT}", &shared_mount) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace( + "{ENVARS}", + &script_envars(&envs, &nsjail_cmd, &script_overrides(&["PATH", "BASE_INTERNAL_URL"], &reserved_variables)), + ), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( diff --git a/backend/windmill-worker/src/php_executor.rs b/backend/windmill-worker/src/php_executor.rs index 8378455ef4..26559e8b37 100644 --- a/backend/windmill-worker/src/php_executor.rs +++ b/backend/windmill-worker/src/php_executor.rs @@ -22,7 +22,8 @@ use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, get_reserved_variables, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, MaybeLock, OccupancyMetrics, + resolve_nsjail_tmp_mount_block, script_envars, start_child_process, MaybeLock, + OccupancyMetrics, }, handle_child::handle_child, is_sandboxing_enabled, COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, @@ -541,19 +542,6 @@ try {{ let child = if is_sandboxing_enabled() { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - let _ = write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_PHP_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{SHARED_MOUNT}", shared_mount) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); let args = vec![ @@ -566,7 +554,6 @@ try {{ nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs(crate::get_otel_context_envs(&job.id)) .env("COMPOSER_HOME", &*COMPOSER_CACHE_DIR) @@ -574,6 +561,20 @@ try {{ .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + let _ = write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_PHP_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{SHARED_MOUNT}", shared_mount) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { let script_path = format!("{job_dir}/wrapper.php"); diff --git a/backend/windmill-worker/src/r_executor.rs b/backend/windmill-worker/src/r_executor.rs index 9b29ae35b6..d273d08785 100644 --- a/backend/windmill-worker/src/r_executor.rs +++ b/backend/windmill-worker/src/r_executor.rs @@ -20,8 +20,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, - DEV_CONF_NSJAIL, + read_result, resolve_nsjail_tmp_mount_block, script_envars, script_overrides, start_child_process, + OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::{self}, @@ -608,28 +608,12 @@ async fn run<'a>( ) .await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_R_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{SHARED_MOUNT}", &shared_mount) - .replace("{R_CACHE_DIR}", &*R_CACHE_DIR) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), - )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() - .current_dir(job_dir) + .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) - .envs(envs) - .envs(reserved_variables) + .envs(&reserved_variables) .envs(R_PROXY_ENVS.clone()) .envs( get_proxy_envs_for_lang( @@ -653,6 +637,25 @@ async fn run<'a>( ]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_R_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{SHARED_MOUNT}", &shared_mount) + .replace("{R_CACHE_DIR}", &*R_CACHE_DIR) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{ENVARS}", + &script_envars(&envs, &cmd, &script_overrides(&["PATH", "BASE_INTERNAL_URL"], &reserved_variables)), + ), + )?; start_child_process(cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( diff --git a/backend/windmill-worker/src/ruby_executor.rs b/backend/windmill-worker/src/ruby_executor.rs index 7871c050c6..1cd477e1fd 100644 --- a/backend/windmill-worker/src/ruby_executor.rs +++ b/backend/windmill-worker/src/ruby_executor.rs @@ -23,8 +23,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, script_envars, + script_overrides, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::{self}, @@ -629,21 +629,11 @@ async fn install<'a>( // true, // crate::common::InstallStrategy::Single(Arc::new(move |dependency| { move |dependency| { - let mut cmd = if jailed { + let nsjail_proto = jailed.then(|| format!("{}.download.config.proto", Uuid::new_v4())); + let mut cmd = if let Some(nsjail_proto) = &nsjail_proto { std::fs::create_dir_all(&dependency.path)?; - let nsjail_proto = format!("{}.download.config.proto", Uuid::new_v4()); - let _ = write_file( - &job_dir, - &nsjail_proto, - &NSJAIL_CONFIG_DOWNLOAD_RUBY_CONTENT - .replace("{TARGET}", &dependency.path) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block) - .replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir), - )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); - cmd.args(vec!["--config", &nsjail_proto, "--", GEM_PATH.as_str()]); + cmd.args(vec!["--config", nsjail_proto, "--", GEM_PATH.as_str()]); cmd } else { Command::new(if cfg!(windows) { @@ -699,10 +689,36 @@ async fn install<'a>( "--source", source.as_str(), ]) - .envs(envs.clone()) - .envs(reserved_variables.clone()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if !jailed { + cmd.envs(&envs); + } + cmd.envs(&reserved_variables); + if let Some(nsjail_proto) = &nsjail_proto { + let set_before = ["PATH", "HOME"] + .into_iter() + .chain(RUBY_PROXY_ENVS.iter().map(|(k, _)| k.as_str())) + .collect::>(); + let _ = write_file( + &job_dir, + nsjail_proto, + &NSJAIL_CONFIG_DOWNLOAD_RUBY_CONTENT + .replace("{TARGET}", &dependency.path) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{ENVARS}", + &script_envars( + &envs, + &cmd, + &script_overrides(&set_before, &reserved_variables), + ), + ), + )?; + } if !verbose { // Make it output minimal info @@ -802,30 +818,13 @@ mount {{ let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_RUBY_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{SHARED_MOUNT}", &shared_mount) - .replace("{SHARED_DEPENDENCIES}", &shared_deps) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() - .current_dir(job_dir) + .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .env("RUBYLIB", rubylib.as_str()) - .envs(envs) - .envs(reserved_variables) + .envs(&reserved_variables) .envs(RUBY_PROXY_ENVS.clone()) .envs( get_proxy_envs_for_lang( @@ -846,6 +845,33 @@ mount {{ ]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_RUBY_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{SHARED_MOUNT}", &shared_mount) + .replace("{SHARED_DEPENDENCIES}", &shared_deps) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace( + "{ENVARS}", + &script_envars( + &envs, + &cmd, + &script_overrides( + &["PATH", "BASE_INTERNAL_URL", "RUBYLIB"], + &reserved_variables, + ), + ), + ), + )?; start_child_process(cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index dff7d58c2d..fabea34499 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -23,7 +23,8 @@ use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, get_reserved_variables, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + resolve_nsjail_tmp_mount_block, script_envars, start_child_process, OccupancyMetrics, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -757,28 +758,10 @@ pub async fn handle_rust_job( let child = if is_sandboxing_enabled() { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - let _ = write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_RUST_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", &*RUST_CACHE_DIR) - .replace("{CACHE_HASH}", &hash) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace("{SHARED_MOUNT}", shared_mount) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs( get_proxy_envs_for_lang( @@ -796,6 +779,24 @@ pub async fn handle_rust_job( .args(vec!["--config", "run.config.proto", "--", "/tmp/main"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + let _ = write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_RUST_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CACHE_DIR}", &*RUST_CACHE_DIR) + .replace("{CACHE_HASH}", &hash) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{SHARED_MOUNT}", shared_mount) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { let compiled_executable_name = &format!("{job_dir}/{RUST_BIN_NAME}"); From 9249aa02d592e60024d7475627cbf664936e4a5d Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 14 Oct 2026 10:23:56 +0200 Subject: [PATCH 02/49] feat: let a linked agent step override model and reasoning effort (#11685) * feat: let a linked agent step override the model and reasoning effort * fix: only honor model and effort overrides the step authored * fix: treat an authored empty effort override as no effort * fix: keep a static empty override from reading caller arguments or the saved effort * fix: keep step tests and multi-agent chats from inventing an effort override * fix: keep the saved model fallback when linked steps differ only on effort * fix: show the agent override hint on the input's own help row * fix: align the override hint spacing between static and expression inputs * fix: keep the override rows a dropdown whatever the model is set to * fix: give the override hint the 4px form gap and drop its trailing period * fix: space the override hint with a margin so it is not clipped * test: pin the saved model fallback when linked steps differ only on effort * fix: keep the saved model fallback across agents that saved different efforts --- backend/windmill-worker/src/ai_executor.rs | 108 ++++++++++++ cli/src/guidance/skills.gen.ts | 6 +- .../lib/components/InputTransformForm.svelte | 7 +- frontend/src/lib/components/ModuleTest.svelte | 12 +- .../copilot/chat/flow/openFlow.json | 2 +- .../copilot/chat/flow/openFlowZod.gen.ts | 8 +- .../components/copilot/chat/global/core.ts | 6 +- .../lib/components/flows/agentFormFields.ts | 43 ++++- .../flows/agentResourceUtils.test.ts | 52 ++++++ .../components/flows/agentResourceUtils.ts | 88 +++++++++- .../flows/content/AgentResourceBar.svelte | 20 ++- .../flows/content/AiAgentStepInputs.svelte | 149 +++++++++++++--- .../flows/content/DynamicInputHelpBox.svelte | 8 +- .../flows/content/FlowModuleComponent.svelte | 12 +- .../conversations/FlowChatInterface.svelte | 28 ++- .../FlowChatModelSettings.svelte | 5 +- .../conversations/agentChatInputs.test.ts | 82 +++++++++ .../flows/conversations/agentChatInputs.ts | 163 +++++++++++++++--- .../src/lib/components/flows/flowInfers.ts | 21 +-- openflow.openapi.yaml | 20 ++- system_prompts/auto-generated/flow.md | 6 +- system_prompts/auto-generated/prompts.ts | 6 +- .../auto-generated/skills/write-flow/SKILL.md | 6 +- system_prompts/base/flow-base.md | 4 + 24 files changed, 760 insertions(+), 102 deletions(-) diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 1b889aea34..01ac2becdd 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -260,6 +260,49 @@ fn keep_authored_memory_id( } } +/// A step's own `model` and `reasoning_effort` inputs replace the provider's, which is how a step +/// linked to a saved agent picks them per run. An empty model keeps the provider's, since a run +/// needs one; an empty effort is no effort, which is how a model that reasons only on request is +/// turned off. A reasoning token is per model, so a different model without an effort of its own +/// runs at its default. +/// Only an input the step authored counts: the arguments of an agent run as a tool also carry +/// whatever the calling model sent, which must not choose the model it runs on. +fn override_provider_model( + provider: &mut ProviderWithResource, + local_args: &HashMap>, + step_input_transforms: &HashMap, +) { + let authored = |key: &str| { + step_input_transforms + .get(key) + .filter(|t| { + matches!( + t, + InputTransform::Javascript { .. } | InputTransform::Static { .. } + ) + }) + .map(|t| match t { + // A static null is not evaluated for a tool, which leaves the caller's argument + // in its place. + InputTransform::Static { value } if value.get() == "null" => String::new(), + _ => local_args + .get(key) + .and_then(|v| serde_json::from_str::(v.get()).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default(), + }) + }; + if let Some(model) = authored("model").filter(|m| !m.is_empty()) { + if model != provider.model { + provider.model = model; + provider.reasoning_effort = None; + } + } + if let Some(effort) = authored("reasoning_effort") { + provider.reasoning_effort = Some(effort).filter(|e| !e.is_empty()); + } +} + /// Reconciles the step's history inputs, the agent's memory policy and the run's memory id. A step /// holds one of two shapes: an older `auto` or `manual` memory, read as the editor that wrote it /// meant it, or the current setting plus the step's own history inputs. Also returns lines for the @@ -854,6 +897,7 @@ pub async fn handle_ai_agent_job( }; keep_authored_memory_id(&mut args, &step_input_transforms); + override_provider_model(&mut args.provider, &local_args, &step_input_transforms); // Nesting is capped at flow → agent → nested agent. When this job is itself a nested tool, // a linked resource's tool set may still contain AIAgent or AIDecision tools (the editor can't @@ -2940,6 +2984,70 @@ mod tests { assert!(bounded_prompt_cache_key(&long).len() <= 64); } + #[test] + fn step_model_and_effort_replace_the_providers() { + use serde_json::json; + let run = |step: serde_json::Value| { + let mut provider: ProviderWithResource = serde_json::from_value(json!({ + "kind": "openai", + "resource": {}, + "model": "saved-model", + "reasoning_effort": "high" + })) + .unwrap(); + let local_args: HashMap> = serde_json::from_value(step).unwrap(); + let authored = local_args + .keys() + .map(|key| { + ( + key.clone(), + InputTransform::Javascript { expr: String::new() }, + ) + }) + .collect(); + override_provider_model(&mut provider, &local_args, &authored); + (provider.model, provider.reasoning_effort) + }; + let saved = ("saved-model".to_string(), Some("high".to_string())); + + // An argument the step did not author is the calling model's, on an agent run as a tool. + let mut provider: ProviderWithResource = serde_json::from_value( + json!({ "kind": "openai", "resource": {}, "model": "saved-model" }), + ) + .unwrap(); + let sent = serde_json::from_value(json!({ "model": "other" })).unwrap(); + override_provider_model(&mut provider, &sent, &HashMap::new()); + assert_eq!(provider.model, "saved-model"); + let null = HashMap::from([( + "model".to_string(), + InputTransform::Static { value: to_raw_value(&serde_json::Value::Null) }, + )]); + override_provider_model(&mut provider, &sent, &null); + assert_eq!(provider.model, "saved-model"); + + assert_eq!(run(json!({})), saved); + assert_eq!(run(json!({ "model": "" })), saved); + assert_eq!(run(json!({ "model": "saved-model" })), saved); + // An effort the step sets to nothing is no effort, not the saved one. + assert_eq!( + run(json!({ "reasoning_effort": null })), + ("saved-model".to_string(), None) + ); + assert_eq!( + run(json!({ "reasoning_effort": "low" })), + ("saved-model".to_string(), Some("low".to_string())) + ); + // The saved effort names a level of the saved model, so it does not follow to another one. + assert_eq!( + run(json!({ "model": "other" })), + ("other".to_string(), None) + ); + assert_eq!( + run(json!({ "model": "other", "reasoning_effort": "low" })), + ("other".to_string(), Some("low".to_string())) + ); + } + #[test] fn overlay_tool_inputs_binds_matching_flowmodule_tool_only() { fn js(expr: &str) -> InputTransform { diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 7cc8f230ad..85848f43aa 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5916,6 +5916,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare \`flow_input.x\` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (\`agent\` set) has no \`provider\` of its own. It exposes the same + two controls through its \`model\` and \`reasoning_effort\` inputs, which replace what the agent + saved: \`"model": { "type": "javascript", "expr": "flow_input.model" }\`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing - \`streaming\` on makes the answer and its thinking appear token by token instead of all at once - \`user_attachments\` points at a flow input typed as an array of s3 objects @@ -6366,7 +6370,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: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"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, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, 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"]}} +{"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: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n\`flow_input.model\` to choose the model per run.\\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside \`provider\`.\\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)\\nand, optionally, the \`model\` and \`reasoning_effort\` overrides of what the agent saved.\\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, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, 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"]}} `, "raw-app": `--- name: raw-app diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 9f8c77ce77..b87afb00f1 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -105,6 +105,8 @@ pickableProperties?: PickableProperties | undefined enableAi?: boolean hideHelpButton?: boolean + /** A footnote under the input, sharing the Help toggle's row when there is one. */ + hint?: string class?: string editor?: SimpleEditor | undefined otherArgs?: Record @@ -150,6 +152,7 @@ pickableProperties = undefined, enableAi = false, hideHelpButton = false, + hint = undefined, class: className = '', editor = $bindable(undefined), otherArgs = {}, @@ -907,7 +910,9 @@ step-input autocompletion (ghost text, accepted with Tab) doesn't hide the Help dropdown — the two stay independent. --> {#if !hideHelpButton && propertyType === 'javascript' && argKind === 'javascript' && arg.expr != undefined} - + + {:else if hint} +

{hint}

{/if} {#snippet innerInput()} diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 8d2b51e8d0..b8fd43e9ff 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -19,8 +19,8 @@ normalizeAgentRef, type LinkedAgentDraft } from './flows/linkedAgentDrafts' - import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils' - import { agentTestInputTransforms } from './flows/agentFormFields' + import { AGENT_LINKED_STEP_KEYS } from './flows/agentResourceUtils' + import { AGENT_OVERRIDE_KEYS, agentTestInputTransforms } from './flows/agentFormFields' import { sendUserToast } from '$lib/toast' import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -186,7 +186,13 @@ const inputTransforms = agentTestInputTransforms( agentVal.agent ? {} : ((agentVal.input_transforms ?? {}) as Record), args, - draft ? AGENT_FLOW_LOCAL_KEYS : Object.keys(args) + // An override the step does not hold is left out: fed from the form it would arrive as an + // authored empty value, which clears the agent's saved effort. + (draft ? AGENT_LINKED_STEP_KEYS : Object.keys(args)).filter( + (key) => + !(AGENT_OVERRIDE_KEYS as readonly string[]).includes(key) || + val.input_transforms?.[key] != undefined + ) ) await jobLoader?.runFlowPreview( diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index 11cee5b724..9ab5c3d1d3 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlow.json +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"version":"1.825.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"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: the n-th retry waits multiplier × seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"},"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 — 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"}},"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"}},"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"}},"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"}},"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"}},"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",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"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"}]},"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 — 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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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 +{"openapi":"3.0.3","info":{"version":"1.830.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"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: the n-th retry waits multiplier × seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too"},"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 — 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"}},"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"}},"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"}},"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"}},"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"}},"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",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"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"}]},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\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 — 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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts index 88cb8fe875..d83ef70207 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional(), "model": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n").optional(), "reasoning_effort": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -17,10 +17,10 @@ export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "i message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\n").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") -export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional(), "model": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n").optional(), "reasoning_effort": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce( (errors, schema) => @@ -37,7 +37,7 @@ export const flowModuleSchema = z.object({ "id": z.string().describe("Unique ide message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\n").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") export const flowModulesSchema = z.array(flowModuleSchema) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index c23161c215..3b70b2b90b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -46,7 +46,7 @@ import type { ScriptLang } from '$lib/gen/types.gen' import { loadSchemaFromModule, memoryPropertyFor } from '$lib/components/flows/flowInfers' -import { flowLocalAgentSchema } from '$lib/components/flows/agentResourceUtils' +import { linkedAgentStepSchema } from '$lib/components/flows/agentResourceUtils' import { AGENT_FIELDS, initialVisibleAgentFields } from '$lib/components/flows/agentFormFields' import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts' import { resolveLinkedAgentTools } from '$lib/components/flows/flowState' @@ -6201,9 +6201,9 @@ async function agentStepRunForm( ): Promise<{ schema: Record; args: Record }> { const transforms = (module.value as { input_transforms?: Record }).input_transforms // A linked step's brain belongs to the resource rather than to this flow, so only the inputs - // this flow supplies are its to edit. + // this flow supplies, and its overrides, are its to edit. const full = (await loadSchemaFromModule(module, workspace)).schema - const schema = (module.value as { agent?: string }).agent ? flowLocalAgentSchema(full) : full + const schema = (module.value as { agent?: string }).agent ? linkedAgentStepSchema(full) : full const visible = initialVisibleAgentFields(transforms, schema.properties) // What the model asked to set, shown even where the step configures nothing: this form has no diff --git a/frontend/src/lib/components/flows/agentFormFields.ts b/frontend/src/lib/components/flows/agentFormFields.ts index e8c5584407..4a9c16f53a 100644 --- a/frontend/src/lib/components/flows/agentFormFields.ts +++ b/frontend/src/lib/components/flows/agentFormFields.ts @@ -10,15 +10,21 @@ import type { InputTransform, MemoryConfig } from '$lib/gen' * the two cannot cycle. */ -export type AgentFieldGroup = 'model' | 'messages' | 'tools' | 'output' +export type AgentFieldGroup = 'model' | 'messages' | 'tools' | 'output' | 'overrides' /** Groups in the order a run assembles the request: pick a model, build the messages, let it call - * tools, shape what comes back. */ -export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string }[] = [ + * tools, shape what comes back. Overrides come last: only a step linked to a saved agent has + * them, and they replace what the agent saved rather than add to the request. */ +export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string; caption?: string }[] = [ { id: 'model', label: 'Model' }, { id: 'messages', label: 'Messages' }, { id: 'tools', label: 'Tools' }, - { id: 'output', label: 'Output' } + { id: 'output', label: 'Output' }, + { + id: 'overrides', + label: 'Agent overrides', + caption: "Replace what is saved on the agent, for this step's runs only." + } ] /** The tool roster, which reads `flowModule.value.tools` rather than an `input_transforms` key. @@ -30,6 +36,17 @@ export const AGENT_TOOLS_ROW = 'tools' export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number] +/** A linked step's overrides of its saved agent. Never seeded either: a step with no override + * holds no key for one. */ +export const AGENT_OVERRIDE_KEYS = ['model', 'reasoning_effort'] as const +export type AgentOverrideKey = (typeof AGENT_OVERRIDE_KEYS)[number] + +/** The inputs whose row exists exactly while the step holds the key. */ +export const AGENT_UNSEEDED_KEYS: readonly string[] = [ + ...AGENT_HISTORY_KEYS, + ...AGENT_OVERRIDE_KEYS +] + /** The input transforms a one-off test of an agent step runs with: each form key is fed from * `flow_input`, over the step's own transforms. A blank history input is unset, as on the step: * an expression evaluating to nothing reads as an empty memory id, and the step's transform is @@ -256,6 +273,24 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [ implicit: true, defaultHint: 'Default: on', textOnly: true + }, + { + key: 'model', + group: 'overrides', + label: 'Model', + tooltip: + "Runs this step on another model than the one saved on the agent, through the agent's own provider resource. Set it to an expression to choose the model per run: an empty value keeps the saved model.", + implicit: '', + defaultHint: 'Default: the model saved on the agent' + }, + { + key: 'reasoning_effort', + group: 'overrides', + label: 'Reasoning effort', + tooltip: + 'Runs this step at another reasoning effort than the one saved on the agent. Set it to an expression to choose the effort per run. An empty value sends no effort, so the model runs at its default. Overriding the model without it does the same.', + implicit: '', + defaultHint: 'Default: the effort saved on the agent' } ] diff --git a/frontend/src/lib/components/flows/agentResourceUtils.test.ts b/frontend/src/lib/components/flows/agentResourceUtils.test.ts index 96b9cf83ff..0799e4357c 100644 --- a/frontend/src/lib/components/flows/agentResourceUtils.test.ts +++ b/frontend/src/lib/components/flows/agentResourceUtils.test.ts @@ -6,6 +6,7 @@ import { flowLocalInputs, inputTransformsToAgentConfig, nonStaticBrainKeys, + providerWithOverrides, summarizeAgentBrain, toolInputOverrides, transformValuedBrainKeys @@ -193,3 +194,54 @@ describe('toolInputOverrides', () => { }) }) }) + +describe('providerWithOverrides', () => { + const saved = { + kind: 'openai', + resource: '$res:u/a/openai', + model: 'gpt-5', + reasoning_effort: 'high' + } + + it('folds static overrides into a static provider, as the worker reads them', () => { + expect( + providerWithOverrides(saved, { reasoning_effort: { type: 'static', value: 'low' } }) + ).toEqual({ type: 'static', value: { ...saved, reasoning_effort: 'low' } }) + // The saved effort names a level of the saved model, so it does not follow to another one. + expect( + providerWithOverrides(saved, { model: { type: 'static', value: 'gpt-5-mini' } }) + ).toEqual({ + type: 'static', + value: { kind: 'openai', resource: '$res:u/a/openai', model: 'gpt-5-mini' } + }) + expect(providerWithOverrides(saved, { model: { type: 'static', value: '' } })).toEqual({ + type: 'static', + value: saved + }) + }) + + it('keeps an expression, resolving as the linked step did', () => { + const run = (overrides: Record, flow_input: Record) => { + const folded = providerWithOverrides( + saved, + Object.fromEntries( + Object.entries(overrides).map(([key, expr]) => [key, { type: 'javascript', expr }]) + ) + ) as { type: 'javascript'; expr: string } + expect(folded.type).toBe('javascript') + return JSON.parse( + JSON.stringify(new Function('flow_input', `return ${folded.expr}`)(flow_input)) + ) + } + const other = { kind: 'openai', resource: '$res:u/a/openai', model: 'gpt-5-mini' } + const effort = { reasoning_effort: 'flow_input.effort' } + const model = { model: 'flow_input.model' } + + expect(run(effort, { effort: 'low' })).toEqual({ ...saved, reasoning_effort: 'low' }) + // An effort the step sets to nothing is no effort, not the saved one. + expect(run(effort, { effort: '' })).toEqual({ ...other, model: 'gpt-5' }) + expect(run(model, { model: '' })).toEqual(saved) + expect(run(model, { model: 'gpt-5-mini' })).toEqual(other) + expect(run({ ...model, ...effort }, { model: 'gpt-5-mini', effort: '' })).toEqual(other) + }) +}) diff --git a/frontend/src/lib/components/flows/agentResourceUtils.ts b/frontend/src/lib/components/flows/agentResourceUtils.ts index 3ee0eaa77e..9eb81a39c9 100644 --- a/frontend/src/lib/components/flows/agentResourceUtils.ts +++ b/frontend/src/lib/components/flows/agentResourceUtils.ts @@ -1,6 +1,11 @@ import { deepEqual } from 'fast-equals' import type { InputTransform } from '$lib/gen' -import { AGENT_FIELDS, describeMemoryPolicy } from './agentFormFields' +import { + AGENT_FIELDS, + AGENT_OVERRIDE_KEYS, + describeMemoryPolicy, + type AgentOverrideKey +} from './agentFormFields' // The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs below are // intentionally excluded — they are supplied per-flow. @@ -31,6 +36,13 @@ export const AGENT_FLOW_LOCAL_KEYS = [ 'previous_messages' ] as const +/** Every input a linked step holds itself: the flow-local ones, and its overrides of what the + * agent saved inside `provider`. */ +export const AGENT_LINKED_STEP_KEYS: readonly string[] = [ + ...AGENT_FLOW_LOCAL_KEYS, + ...AGENT_OVERRIDE_KEYS +] + export type AgentTool = Record /** @@ -59,7 +71,7 @@ export function nonStaticBrainKeys( } /** - * Keep only the flow-local inputs on a step's input_transforms. Used when linking: the brain comes + * Keep only the inputs a linked step holds on a step's input_transforms. Used when linking: the brain comes * from the resource, so the step must not carry stale `provider`/`system_prompt`/… transforms — at * runtime they'd still be resolved (an unnecessary, possibly failing provider `$res:` fetch) yet * never used, since the linked branch takes the brain from the resource. @@ -68,7 +80,7 @@ export function flowLocalInputs( inputTransforms: Record | undefined ): Record { const out: Record = {} - for (const key of AGENT_FLOW_LOCAL_KEYS) { + for (const key of AGENT_LINKED_STEP_KEYS) { if (inputTransforms?.[key]) { out[key] = inputTransforms[key] } @@ -149,6 +161,74 @@ export function flowLocalAgentSchema(schema: any): any { } } +/** + * The schema of a step linked to a saved agent, in a flow: its flow-local inputs, plus the saved + * settings it may override. + */ +export function linkedAgentStepSchema(schema: any): any { + const local = flowLocalAgentSchema(schema) + if (!local?.properties) { + return local + } + return { + ...local, + properties: { + ...local.properties, + model: { + type: 'string', + description: "Replaces the model saved on the agent for this step's runs." + }, + reasoning_effort: { + type: 'string', + description: "Replaces the reasoning effort saved on the agent for this step's runs." + } + }, + order: [...local.order, ...AGENT_OVERRIDE_KEYS] + } +} + +/** + * The `provider` of a step unlinked from its agent: the saved one with the step's overrides folded + * in, since an unlinked step sets model and effort nowhere else. Follows the worker's + * `override_provider_model`, so the step runs on what it ran on while linked. + */ +export function providerWithOverrides( + provider: any, + overrides: Partial> | undefined +): InputTransform | undefined { + if (!provider || typeof provider !== 'object') return undefined + const authored = (key: AgentOverrideKey): any => { + const t = overrides?.[key] as any + return t?.type === 'static' || (t?.type === 'javascript' && t.expr) ? t : undefined + } + const text = (t: any): string => (typeof t.value === 'string' ? t.value.trim() : '') + const model = authored('model') + const effort = authored('reasoning_effort') + const { model: savedModel, reasoning_effort: savedEffort, ...rest } = provider + + if (model?.type !== 'javascript' && effort?.type !== 'javascript') { + const runModel = (model && text(model)) || savedModel + const runEffort = effort ? text(effort) : runModel === savedModel ? savedEffort : undefined + return { + type: 'static', + value: { ...rest, model: runModel, ...(runEffort ? { reasoning_effort: runEffort } : {}) } + } + } + const literal = (value: unknown) => (value === undefined ? 'undefined' : JSON.stringify(value)) + const source = (t: any) => (t.type === 'javascript' ? `(${t.expr})` : literal(text(t))) + // One object literal, the shape the flow's expression evaluator and the chat composer both read. + const runModel = model ? `(${source(model)} || ${literal(savedModel)})` : literal(savedModel) + const runEffort = effort + ? `${source(effort)} || undefined` + : `${runModel} === ${literal(savedModel)} ? ${literal(savedEffort)} : undefined` + const fields = [ + ...Object.entries(rest).map(([key, value]) => `${JSON.stringify(key)}: ${literal(value)}`), + `model: ${runModel}`, + `reasoning_effort: ${runEffort}` + ] + return { type: 'javascript', expr: `({ ${fields.join(', ')} })` } +} + /** Read off the form's own registry, so a linked agent's summary cannot name a field differently * from the form that edits it. */ export const AGENT_BRAIN_LABELS: Record = Object.fromEntries( @@ -182,7 +262,7 @@ export function summarizeAgentBrain( if (v === undefined || v === null || v === '') continue let value: string if (key === 'provider') { - value = [v.kind, v.model].filter(Boolean).join(' · ') || 'configured' + value = [v.kind, v.model, v.reasoning_effort].filter(Boolean).join(' · ') || 'configured' } else if (key === 'memory') { value = typeof v === 'object' ? describeMemoryPolicy(v) : String(v) } else if (key === 'output_schema') { diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index 3359f20080..6aa5a902e8 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -18,6 +18,7 @@ flowLocalInputs, inputTransformsToAgentConfig, nonStaticBrainKeys, + providerWithOverrides, summarizeAgentBrain, type AIAgentConfig, type AgentTool @@ -61,7 +62,7 @@ flowPath = '', fromAgentEditor = false, chatInputEnabled = false, - linkedMemory = $bindable() + linkedAgent = $bindable() }: { agent: string | undefined inputTransforms: Record @@ -80,8 +81,9 @@ // is the wrong way in. fromAgentEditor?: boolean chatInputEnabled?: boolean - // The linked agent's memory once its config has loaded, for the step's history inputs. - linkedMemory?: { memory: unknown } | undefined + // The linked agent's memory and provider once its config has loaded, for the step's history + // inputs and its overrides. + linkedAgent?: { memory: unknown; provider?: unknown } | undefined } = $props() let ws = $derived(opWorkspace ?? $operatingWorkspace) @@ -226,7 +228,9 @@ loadedInfo?.ws === ws && loadedInfo?.path === agent ? loadedInfo : undefined ) $effect(() => { - linkedMemory = linkedInfo ? { memory: linkedInfo.config?.memory } : undefined + linkedAgent = linkedInfo + ? { memory: linkedInfo.config?.memory, provider: linkedInfo.config?.provider } + : undefined }) let inheritedTools = $derived(linkedInfo?.tools ?? []) let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config)) @@ -512,7 +516,13 @@ local[key] = inputTransforms[key] } } - const forkedInputs = { ...agentConfigToInputTransforms(cfg), ...local } + // An unlinked step sets model and effort inside `provider`, so its overrides move there. + const provider = providerWithOverrides(cfg.provider, inputTransforms) + const forkedInputs = { + ...agentConfigToInputTransforms(cfg), + ...(provider ? { provider } : {}), + ...local + } const forkedTools = cfg.tools ?? [] inputTransforms = forkedInputs for (const tool of forkedTools) { diff --git a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte index 4e7c7692a5..a123ce9185 100644 --- a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte +++ b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte @@ -29,7 +29,7 @@ -
+
+ {#if hint} + + {hint} + {/if}
{#if opened} diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 1ca2ed94fc..5694965663 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -33,7 +33,7 @@ import AiAgentStepInputs from './AiAgentStepInputs.svelte' import AgentToolBindings from './AgentToolBindings.svelte' import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte' - import { flowLocalAgentSchema } from '../agentResourceUtils' + import { linkedAgentStepSchema } from '../agentResourceUtils' import { AI_AGENT_TOOL_AI_KEYS, AI_DECISION_TOOL_AI_KEYS } from '../agentToolUtils' import { setAiDecisionStep } from '../aiDecisionBranching' import DiffEditor from '$lib/components/DiffEditor.svelte' @@ -309,7 +309,7 @@ let inputTransformSchemaForm: { setArgs: (nargs: Record) => void } | undefined = $state(undefined) // The linked agent's memory, which decides which history inputs the step offers. - let linkedAgentMemory: { memory: unknown } | undefined = $state(undefined) + let linkedAgentConfig: { memory: unknown; provider?: unknown } | undefined = $state(undefined) let reloadError: string | undefined = $state(undefined) async function reload(flowModule: FlowModule) { @@ -1180,7 +1180,7 @@ opWorkspace={opWs} flowPath={$pathStore} fromAgentEditor={agentEditorHost?.() != undefined} - bind:linkedMemory={linkedAgentMemory} + bind:linkedAgent={linkedAgentConfig} chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false} bind:agent={ () => @@ -1233,7 +1233,7 @@ bind:this={inputTransformSchemaForm} pickableProperties={stepPropPicker.pickableProperties} schema={agentLinked - ? flowLocalAgentSchema( + ? linkedAgentStepSchema( flowStateStore.val[flowModule.id]?.schema ?? {} ) : (flowStateStore.val[flowModule.id]?.schema ?? {})} @@ -1263,7 +1263,7 @@ chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false} workspace={opWs} visibilityKey={agentFieldsKey} - linkedMemory={agentLinked ? linkedAgentMemory : undefined} + linkedAgent={agentLinked ? linkedAgentConfig : undefined} {agentLinked} tools={agentLinked ? getLinkedAgentTools( @@ -1367,7 +1367,7 @@ mod={flowModule} {noEditor} schema={agentLinked - ? flowLocalAgentSchema(flowStateStore.val[flowModule.id]?.schema ?? {}) + ? linkedAgentStepSchema(flowStateStore.val[flowModule.id]?.schema ?? {}) : (flowStateStore.val[flowModule.id]?.schema ?? {})} bind:testJob bind:testIsLoading diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index f6cc9af6f5..9d7f39ed2c 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -12,7 +12,8 @@ import { tick, untrack } from 'svelte' import type { Chat } from 'windmill-chat' import { saveFlowChatInputs } from './flowChatProps' - import type { FlowModule } from '$lib/gen' + import { ResourceService, type FlowModule } from '$lib/gen' + import { resource } from 'runed' import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte' import { attachmentsTargetFor, @@ -23,10 +24,12 @@ import FlowChatModelSettings from './FlowChatModelSettings.svelte' import { agentModelGap, + chatLinkedAgentPaths, composerOwnedInputs, resolveAgentModelWiring, showsModelButton, - withoutRejectedEffort + withoutRejectedEffort, + type SavedAgentProviders } from './agentChatInputs' interface Props { @@ -107,7 +110,26 @@ // The model gets its own button, shaped like the copilot's model settings, driven by // whichever provider fields the flow exposes. Attachments are the paperclip's; every other // flow input is asked for in the Configure-inputs modal. - const modelWiring = $derived(resolveAgentModelWiring(flowModules)) + // A step linked to a saved agent takes its provider from the agent, so that is read here. One + // the reader cannot load leaves the wiring unreadable, and the model button away. + const savedProviders = resource( + () => ({ workspace, paths: chatLinkedAgentPaths(flowModules) }), + async ({ workspace, paths }): Promise => { + if (!workspace || paths.length === 0) return {} + const entries = await Promise.all( + paths.map(async (path) => { + try { + const value: any = await ResourceService.getResourceValue({ workspace, path }) + return [path, value?.provider] as const + } catch { + return [path, undefined] as const + } + }) + ) + return Object.fromEntries(entries) + } + ) + const modelWiring = $derived(resolveAgentModelWiring(flowModules, savedProviders.current)) // An agent with nothing to call cannot answer, and the composer cannot fix it, so the // chat says what to go and do instead of offering controls that write nowhere. const modelGap = $derived(agentModelGap(modelWiring, subject)) diff --git a/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte b/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte index f27e14749e..f777a4cf49 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte @@ -25,6 +25,7 @@ import { resource } from 'runed' import { composerDrivenFields, + wiredFieldValue, type AgentModelWiring, type ProviderField } from './agentChatInputs' @@ -40,9 +41,7 @@ let { wiring, values, setValue, workspace }: Props = $props() function fieldValue(field: ProviderField): any { - if (wiring.whole) return values[wiring.whole]?.[field] - const name = wiring.fields[field] - return name ? values[name] : wiring.fixed[field] + return wiredFieldValue(wiring, values, field) } const driven = $derived(composerDrivenFields(wiring)) diff --git a/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts b/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts index 00561330d0..6855f8bbf7 100644 --- a/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts +++ b/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts @@ -6,6 +6,7 @@ import { parseProviderTransform, resolveAgentModelWiring, showsModelButton, + wiredFieldValue, withoutRejectedEffort } from './agentChatInputs' import type { FlowModule } from '$lib/gen' @@ -499,3 +500,84 @@ describe('parseProviderTransform with comments', () => { ).toBeUndefined() }) }) + +describe('a step linked to a saved agent', () => { + const saved = { + 'u/a/agent': { + kind: 'anthropic', + resource: '$res:u/a/claude', + model: 'claude-sonnet-4-5', + reasoning_effort: 'high' + } + } + function linked(input_transforms: Record): FlowModule { + return { + id: 'a', + value: { type: 'aiagent', agent: '$res:u/a/agent', tools: [], input_transforms } + } as unknown as FlowModule + } + const wired = linked({ + model: { type: 'javascript', expr: 'flow_input.model' }, + reasoning_effort: { type: 'javascript', expr: 'flow_input.effort' } + }) + + it('drives the overrides it wires, on the provider the agent saved', () => { + const wiring = resolveAgentModelWiring([wired], saved) + expect(wiring?.fields).toEqual({ model: 'model', reasoning_effort: 'effort' }) + expect(wiring?.fixed).toEqual({ kind: 'anthropic', resource: '$res:u/a/claude' }) + expect(agentModelWiringInputs(wiring).sort()).toEqual(['effort', 'model']) + }) + + it('shows the saved model for a model input left empty, and no effort for an empty effort', () => { + const wiring = resolveAgentModelWiring([wired], saved)! + expect(wiredFieldValue(wiring, {}, 'model')).toBe('claude-sonnet-4-5') + // Empty is how a model that reasons only on request is turned off. + expect(wiredFieldValue(wiring, { effort: '' }, 'reasoning_effort')).toBe('') + }) + + it('shows the saved effort where the step sets none, only on the saved model', () => { + const wiring = resolveAgentModelWiring( + [linked({ model: { type: 'javascript', expr: 'flow_input.model' } })], + saved + )! + expect(wiredFieldValue(wiring, {}, 'reasoning_effort')).toBe('high') + const none = resolveAgentModelWiring( + [linked({ reasoning_effort: { type: 'static', value: '' } })], + saved + )! + expect(wiredFieldValue(none, {}, 'reasoning_effort')).toBe('') + expect( + wiredFieldValue(wiring, { model: 'claude-haiku-4-5' }, 'reasoning_effort') + ).toBeUndefined() + }) + + it('does not show the saved effort for steps that fix different ones', () => { + const effort = (value: string) => + linked({ + model: { type: 'javascript', expr: 'flow_input.model' }, + reasoning_effort: { type: 'static', value } + }) + const wiring = resolveAgentModelWiring([effort('low'), effort('medium')], saved)! + expect(wiredFieldValue(wiring, {}, 'reasoning_effort')).toBeUndefined() + expect(wiredFieldValue(wiring, {}, 'model')).toBe('claude-sonnet-4-5') + + // Two agents that share a model and saved different efforts are in the same position. + const other = { ...wired, value: { ...wired.value, agent: '$res:u/a/other' } } as FlowModule + const two = resolveAgentModelWiring([wired, other], { + ...saved, + 'u/a/other': { ...saved['u/a/agent'], reasoning_effort: 'low' } + })! + expect(wiredFieldValue(two, {}, 'model')).toBe('claude-sonnet-4-5') + expect(two.saved?.reasoning_effort).toBeUndefined() + }) + + it('cannot be read until the agent has loaded, or with an expression it cannot place', () => { + expect(resolveAgentModelWiring([wired], {})).toBeUndefined() + expect( + resolveAgentModelWiring( + [linked({ model: { type: 'javascript', expr: 'pick(flow_input.model)' } })], + saved + ) + ).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/flows/conversations/agentChatInputs.ts b/frontend/src/lib/components/flows/conversations/agentChatInputs.ts index 1c178bde26..6cfe730572 100644 --- a/frontend/src/lib/components/flows/conversations/agentChatInputs.ts +++ b/frontend/src/lib/components/flows/conversations/agentChatInputs.ts @@ -4,6 +4,12 @@ import { carriedReasoning } from '$lib/components/copilot/chatModelSettings' import { parseExpressionAt } from 'acorn' import { agentSteps, flowInputReads } from './agentAttachmentInput' +/** A step names its agent bare or as `$res:`/`res://`, as `linkedAgentDrafts` reads it; + * kept apart from that module, which loads the editor's draft stores. */ +function normalizeAgentRef(agentRef: string): string { + return agentRef.replace(/^\$res:/, '').replace(/^res:\/\//, '') +} + /** Block and line comments removed, so what is left is only what affects the value. */ function withoutComments(source: string): string { return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '') @@ -78,6 +84,82 @@ export type AgentModelWiring = { * which describe what the composer may offer. */ someAgentCannotRun?: boolean + /** + * What a step linked to a saved agent runs on where it overrides nothing: an empty model + * input keeps the saved model, and the saved effort applies only on that model, to a step + * that sets no effort of its own. + */ + saved?: { model?: string; reasoning_effort?: string } +} + +/** The provider saved on each linked agent, by resource path. */ +export type SavedAgentProviders = Record | undefined> + +/** The saved agents whose provider the wiring needs, as bare resource paths. */ +export function chatLinkedAgentPaths(modules: FlowModule[] | undefined): string[] { + const paths = chatFacingAgents(modules) + .map((module) => (module.value as any).agent as string | undefined) + .filter((agent): agent is string => !!agent) + .map(normalizeAgentRef) + return [...new Set(paths)].sort() +} + +/** + * Read a step linked to a saved agent: the provider is the agent's, except for the model and + * effort the step overrides, each a literal or one flow input. + */ +function parseLinkedStep( + provider: Record | undefined, + transforms: Record | undefined +): AgentModelWiring | undefined { + if (!provider || typeof provider !== 'object') return undefined + const fixed: AgentModelWiring['fixed'] = {} + for (const field of PROVIDER_FIELDS) { + if (provider[field] !== undefined) fixed[field] = provider[field] + } + const saved = { model: fixed.model, reasoning_effort: fixed.reasoning_effort } + // Read off `saved` by whoever shows it, since it holds only on the saved model. + delete fixed.reasoning_effort + const fields: AgentModelWiring['fields'] = {} + for (const field of ['model', 'reasoning_effort'] as const) { + const transform = transforms?.[field] as any + if (!transform) continue + if (transform.type === 'static') { + const value = typeof transform.value === 'string' ? transform.value.trim() : '' + // An empty model keeps the saved one; an empty effort is the step's choice of none. + if (value || field === 'reasoning_effort') fixed[field] = value + continue + } + if (transform.type !== 'javascript') return undefined + const name = flowInputName(parseExpression(transform.expr)) + if (!name) return undefined + fields[field] = name + delete fixed[field] + } + return { fields, fixed, saved } +} + +/** + * The value a provider field runs on, given what the composer holds, as the worker's + * `override_provider_model` resolves a linked agent's. + */ +export function wiredFieldValue( + wiring: AgentModelWiring, + values: Record, + field: ProviderField +): any { + if (wiring.whole) return values[wiring.whole]?.[field] + const name = wiring.fields[field] + const own = name ? values[name] : wiring.fixed[field] + if (!wiring.saved) return own + if (field === 'model') return own || wiring.saved.model + // An effort the step overrides is the run's whatever it holds: empty there is no effort. + if (field === 'reasoning_effort' && !name && own === undefined) { + return wiredFieldValue(wiring, values, 'model') === wiring.saved.model + ? wiring.saved.reasoning_effort + : own + } + return own } /** The flow input behind `flow_input.x`, `flow_input?.x` or `flow_input['x']`. */ @@ -121,23 +203,8 @@ export function parseProviderTransform( } if (transform?.type !== 'javascript') return undefined - // The author's own text may already be wrapped, so any balanced surround is fine — what - // the span check rejects is an expression with something else beside it. - const source = parenthesised(transform.expr) - let node: any - try { - node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' }) - } catch { - return undefined - } - // A comment beside the expression is not another expression: the author annotating their - // own provider must not cost them the model control. Dropped before the check so what - // remains is only what would change the value — and so a paren inside a comment is not - // counted as one of the wrapping pair. - const before = withoutComments(source.slice(0, node.start)) - const after = withoutComments(source.slice(node.end)) - if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined - if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined + const node = parseExpression(transform.expr) + if (!node) return undefined const whole = flowInputName(node) if (whole) return { whole, fields: {}, fixed: {} } @@ -162,6 +229,28 @@ export function parseProviderTransform( return { fields, fixed } } +/** The one expression a transform holds, or undefined where it is anything more than that. */ +function parseExpression(expr: string): any { + // The author's own text may already be wrapped, so any balanced surround is fine — what + // the span check rejects is an expression with something else beside it. + const source = parenthesised(expr) + let node: any + try { + node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' }) + } catch { + return undefined + } + // A comment beside the expression is not another expression: the author annotating their + // own provider must not cost them the model control. Dropped before the check so what + // remains is only what would change the value — and so a paren inside a comment is not + // counted as one of the wrapping pair. + const before = withoutComments(source.slice(0, node.start)) + const after = withoutComments(source.slice(node.end)) + if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined + if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined + return node +} + /** How one agent supplies a provider field: from an input, as a literal, or not at all. */ type FieldSupply = | { kind: 'wired'; name: string } @@ -192,12 +281,16 @@ function fieldSupply(wiring: AgentModelWiring, field: ProviderField): FieldSuppl * and field-by-field wiring is ambiguous throughout and yields nothing. */ export function resolveAgentModelWiring( - modules: FlowModule[] | undefined + modules: FlowModule[] | undefined, + savedProviders: SavedAgentProviders = {} ): AgentModelWiring | undefined { const agents = chatFacingAgents(modules) - const parsed = agents.map((agent) => - parseProviderTransform((agent.value as any).input_transforms?.['provider']) - ) + const parsed = agents.map((agent) => { + const value = agent.value as any + return value.agent + ? parseLinkedStep(savedProviders[normalizeAgentRef(value.agent)], value.input_transforms) + : parseProviderTransform(value.input_transforms?.['provider']) + }) if (parsed.length === 0) return undefined // An agent whose provider cannot be read is an agent the composer cannot speak for: // dropping it would let the rest declare a control that governs only some of them. @@ -219,19 +312,37 @@ export function resolveAgentModelWiring( const fields: AgentModelWiring['fields'] = {} const fixed: AgentModelWiring['fixed'] = {} + const disagreed = new Set() for (const field of PROVIDER_FIELDS) { // Every agent has to supply the field the same way for the composer to speak for // them all. One wired name among agents that otherwise fix it is not agreement: // the control would move that one agent and leave the others where they are. const supplies = new Set(wirings.map((w) => JSON.stringify(fieldSupply(w, field)))) + // Disagreement leaves the field neither editable nor known: a control offered here // would govern one agent while the rest ran on something else. - if (supplies.size > 1) continue + if (supplies.size > 1) { + disagreed.add(field) + continue + } const supply: FieldSupply = JSON.parse([...supplies][0]) if (supply.kind === 'wired') fields[field] = supply.name else if (supply.kind === 'fixed') fixed[field] = supply.value } - return { fields, fixed, someAgentCannotRun } + // An empty input means one thing only where every agent falls back to the same saved value, + // and a saved value must not stand in for a field the agents disagree about. The saved effort + // belongs to the saved model, so it is only kept alongside one. + const common = (field: 'model' | 'reasoning_effort') => { + const values = new Set(wirings.map((w) => w.saved?.[field])) + return values.size === 1 && !disagreed.has(field) ? [...values][0] : undefined + } + const model = common('model') + return { + fields, + fixed, + someAgentCannotRun, + saved: model === undefined ? undefined : { model, reasoning_effort: common('reasoning_effort') } + } } /** @@ -357,11 +468,9 @@ export function withoutRejectedEffort( const effortInput = wiring.fields.reasoning_effort if (!effortInput) return values - const kindInput = wiring.fields.kind - const modelInput = wiring.fields.model const rejected = rejectsEffort( - kindInput ? values[kindInput] : wiring.fixed.kind, - modelInput ? values[modelInput] : wiring.fixed.model, + wiredFieldValue(wiring, values, 'kind'), + wiredFieldValue(wiring, values, 'model'), values[effortInput] ) return rejected ? { ...values, [effortInput]: '' } : values diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index f2beefa0de..38a9a02c2d 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -3,8 +3,8 @@ import { loadSchemaFlow } from '$lib/scripts' import type { Schema } from '$lib/common' import { emptySchema } from '$lib/utils' import type { FlowModule, InputTransform } from '$lib/gen' -import { AGENT_FLOW_LOCAL_KEYS } from './agentResourceUtils' -import { AGENT_HISTORY_KEYS } from './agentFormFields' +import { AGENT_LINKED_STEP_KEYS } from './agentResourceUtils' +import { AGENT_UNSEEDED_KEYS } from './agentFormFields' /** Display names for the memory field's options, so anything else naming the setting an author * picked cannot drift from the button they see. */ @@ -390,20 +390,17 @@ export async function loadSchemaFromModule( } } else if (mod.type === 'aiagent') { let input_transforms = migrateAiAgentInputTransforms(mod.input_transforms ?? {}) - // A linked step's brain lives in the resource, so only the flow-local inputs get a placeholder - // transform: filling the brain keys back in would re-add the very fields linking strips, and - // they would be persisted on the next save. - const keys = mod.agent - ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) - : Object.keys(AI_AGENT_SCHEMA.properties ?? {}) + // A linked step's brain lives in the resource, so only the inputs it holds itself are kept: + // filling the brain keys back in would re-add the very fields linking strips, and they would + // be persisted on the next save. + const keys = mod.agent ? AGENT_LINKED_STEP_KEYS : Object.keys(AI_AGENT_SCHEMA.properties ?? {}) return { input_transforms: keys.reduce((accu, key) => { const transform = input_transforms[key] ?? - // A present history input is the step's choice at runtime, so it gets no placeholder. - ((AGENT_HISTORY_KEYS as readonly string[]).includes(key) - ? undefined - : { type: 'static', value: undefined }) + // A present history input or override is the step's choice at runtime, so it gets no + // placeholder. + (AGENT_UNSEEDED_KEYS.includes(key) ? undefined : { type: 'static', value: undefined }) if (transform) accu[key] = transform return accu }, {}), diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0242dc7f04..e216e9df8d 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1183,6 +1183,23 @@ components: description: | Number. Limits how many times the agent can loop through reasoning and tool use. Range: 1-1000. + model: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + String. Only on a step linked to a saved agent (see `agent`): replaces the model + saved on the agent for this step's runs, on the agent's own provider resource. An + empty value keeps the saved model. Set it to an expression such as + `flow_input.model` to choose the model per run. + reasoning_effort: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning + effort saved on the agent for this step's runs, as a provider-native token (e.g. + 'low', 'high', 'none'). An empty value sends no effort, so the model runs at its + default. Leave the input out to keep the saved effort, which holds only while the + step runs the saved model. A step that is not linked sets both inside `provider`. # Only the flow-local inputs are always present: a step linked to an `ai_agent` resource # (see `agent`) keeps just those and takes provider/output_type from the resource. Even # `user_message` may be absent, when memory is off and `previous_messages` is the prompt. @@ -1208,7 +1225,8 @@ components: Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain config (provider/model/system prompt/etc.) and tool set are resolved at runtime from that resource; the module's input_transforms then only carry the flow-local inputs - (user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages). + (user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages) + and, optionally, the `model` and `reasoning_effort` overrides of what the agent saved. tool_inputs: type: object description: | diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index e5dd862a3b..747f7dc715 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -149,6 +149,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same + two controls through its `model` and `reasoning_effort` inputs, which replace what the agent + saved: `"model": { "type": "javascript", "expr": "flow_input.model" }`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - `memory` is what lets the agent see earlier turns; without it every message starts from nothing - `streaming` on makes the answer and its thinking appear token by token instead of all at once - `user_attachments` points at a flow input typed as an array of s3 objects @@ -598,4 +602,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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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 +{"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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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 226e1d89c0..3667cf5e56 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -176,6 +176,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare \`flow_input.x\` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (\`agent\` set) has no \`provider\` of its own. It exposes the same + two controls through its \`model\` and \`reasoning_effort\` inputs, which replace what the agent + saved: \`"model": { "type": "javascript", "expr": "flow_input.model" }\`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing - \`streaming\` on makes the answer and its thinking appear token by token instead of all at once - \`user_attachments\` points at a flow input typed as an array of s3 objects @@ -3615,7 +3619,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: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"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, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, 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"]}}`; +{"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: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n\`flow_input.model\` to choose the model per run.\\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside \`provider\`.\\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)\\nand, optionally, the \`model\` and \`reasoning_effort\` overrides of what the agent saved.\\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, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, 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 4b0889c3d8..4ede1e815f 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -236,6 +236,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same + two controls through its `model` and `reasoning_effort` inputs, which replace what the agent + saved: `"model": { "type": "javascript", "expr": "flow_input.model" }`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - `memory` is what lets the agent see earlier turns; without it every message starts from nothing - `streaming` on makes the answer and its thinking appear token by token instead of all at once - `user_attachments` points at a flow input typed as an array of s3 objects @@ -686,4 +690,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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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"]}} +{"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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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 ff10f38062..f6734afe58 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -149,6 +149,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same + two controls through its `model` and `reasoning_effort` inputs, which replace what the agent + saved: `"model": { "type": "javascript", "expr": "flow_input.model" }`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - `memory` is what lets the agent see earlier turns; without it every message starts from nothing - `streaming` on makes the answer and its thinking appear token by token instead of all at once - `user_attachments` points at a flow input typed as an array of s3 objects From 88c3cc0ced8fe3183979f0abd8e05846f6f4a8e6 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 14 Oct 2026 10:24:10 +0200 Subject: [PATCH 03/49] feat: guide the AI when no resource type fits a service (#11615) * feat: guide the AI when no resource type fits a service Co-Authored-By: Claude Opus 5.5 * fix: report a failed resource type search and model a synced instance in evals Co-Authored-By: Claude Opus 5.5 * fix: keep named resource types first and list only related names on search failure Co-Authored-By: Claude Opus 5.5 * fix: point the sync note at the hub sync button only Co-Authored-By: Claude Opus 5.5 * refactor: list the instance's resource types when the search finds nothing Co-Authored-By: Claude Opus 5.5 * docs: let the user create a resource type instead of syncing the hub Co-Authored-By: Claude Opus 5.5 * docs: make the linked-variable resource the default, not the only option Co-Authored-By: Claude Opus 5.5 * test: add a cli eval for a service without a hub resource type Co-Authored-By: Claude Opus 5.5 * feat: keep the resource type search fresh and serve it without embeddings Co-Authored-By: Claude Opus 5.5 * feat: let the global chat create a resource type after the user confirms it Co-Authored-By: Claude Opus 5.5 * test: simulate create_resource_type in global evals and fix the in-house API checklists Co-Authored-By: Claude Opus 5.5 * fix: reuse unchanged custom resource type vectors and match names case-insensitively Co-Authored-By: Claude Opus 5.5 * fix: add the unsynced note whatever the resource type search returns Co-Authored-By: Claude Opus 5.5 * fix: carry the no-type rule into flow inputs and script parameters, and ask the chat to relay the sync Co-Authored-By: Claude Opus 5.5 * docs: tell script chat to hand the user the schema it cannot create Co-Authored-By: Claude Opus 5.5 * fix: skip the resource type fingerprint query when no index is loaded Co-Authored-By: Claude Opus 5.5 * feat: index resource types locally when the hub's embeddings cannot be fetched Co-Authored-By: Claude Opus 5.5 * fix: match short service names in the resource type text fallback Co-Authored-By: Claude Opus 5.5 * fix: name custom resource types with the c_ prefix the Resources page uses Co-Authored-By: Claude Opus 5.5 * test: require the c_ prefix in in-house API evals and a real sync mention Co-Authored-By: Claude Opus 5.5 * test: cover workspace scoping of the resource type text search Co-Authored-By: Claude Opus 5.5 * docs: say the form adds the c_ prefix unless an admin turns it off Co-Authored-By: Claude Opus 5.5 * fix: refresh resource type names and the resources tab after the chat creates a type Co-Authored-By: Claude Opus 5.5 * perf: fingerprint resource types without sorting every schema Co-Authored-By: Claude Opus 5.5 * fix: tell the global chat in each message when the hub's types are not synced Co-Authored-By: Claude Opus 5.5 * fix: send users who cannot deploy to an admin for a resource type, and have the cli list types first Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015YR13nJWziUSWktZwhC7dx * refactor: move the resource type search index changes to their own pull request Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015YR13nJWziUSWktZwhC7dx --------- Co-authored-by: Claude Opus 5.5 --- .../frontend/core/global/globalEvalRunner.ts | 2 + ai_evals/adapters/frontend/mockBackend.ts | 117 ++- .../adapters/frontend/vitestAdapter.test.ts | 22 +- ai_evals/cases/cli.yaml | 16 + ai_evals/cases/flow.yaml | 10 + ai_evals/cases/global.yaml | 59 ++ ai_evals/cases/script.yaml | 11 + .../global/hub/synced_resource_types.json | 893 ++++++++++++++++++ .../initial/resource_types_not_synced.json | 5 + .../commands/resource-type/resource-type.ts | 19 +- cli/src/guidance/skills.gen.ts | 82 +- cli/test/standalone_commands.test.ts | 5 +- .../copilot/chat/AIChatManager.svelte.ts | 20 +- .../copilot/chat/global/core.test.ts | 26 + .../components/copilot/chat/global/core.ts | 89 +- .../copilot/chat/script/core.test.ts | 45 +- .../components/copilot/chat/script/core.ts | 93 +- .../src/lib/components/copilot/chat/shared.ts | 3 + .../components/sessions/previewReload.test.ts | 1 + .../lib/components/sessions/previewReload.ts | 2 + system_prompts/auto-generated/flow.md | 8 + system_prompts/auto-generated/prompts.ts | 24 +- system_prompts/auto-generated/script.md | 2 + .../auto-generated/skills/resources/SKILL.md | 14 +- .../auto-generated/skills/write-flow/SKILL.md | 8 + .../skills/write-script-ansible/SKILL.md | 3 + .../skills/write-script-bash/SKILL.md | 3 + .../skills/write-script-bigquery/SKILL.md | 3 + .../skills/write-script-bun/SKILL.md | 3 + .../skills/write-script-bunnative/SKILL.md | 3 + .../skills/write-script-csharp/SKILL.md | 3 + .../skills/write-script-deno/SKILL.md | 3 + .../skills/write-script-duckdb/SKILL.md | 3 + .../skills/write-script-go/SKILL.md | 3 + .../skills/write-script-graphql/SKILL.md | 3 + .../skills/write-script-java/SKILL.md | 3 + .../skills/write-script-mssql/SKILL.md | 3 + .../skills/write-script-mysql/SKILL.md | 3 + .../skills/write-script-php/SKILL.md | 3 + .../skills/write-script-postgresql/SKILL.md | 3 + .../skills/write-script-powershell/SKILL.md | 3 + .../skills/write-script-python3/SKILL.md | 3 + .../skills/write-script-rlang/SKILL.md | 3 + .../skills/write-script-rust/SKILL.md | 3 + .../skills/write-script-snowflake/SKILL.md | 3 + system_prompts/base/flow-base.md | 8 + system_prompts/base/resources.md | 14 +- system_prompts/base/script-base.md | 5 + 48 files changed, 1597 insertions(+), 66 deletions(-) create mode 100644 ai_evals/fixtures/frontend/global/hub/synced_resource_types.json create mode 100644 ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts index fe20d77d05..9dabc61a13 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -13,6 +13,7 @@ import { getGlobalDraft, listGlobalDrafts, } from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter"; +import { resolveResourceTypeSync } from "../../../../../frontend/src/lib/components/copilot/chat/script/core"; import { appendPlanModeInstructions } from "../../../../../frontend/src/lib/components/copilot/chat/planMode"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; import { createEvalPlanTools } from "./planModeTools"; @@ -202,6 +203,7 @@ export async function runGlobalEval( userMessage: prepareGlobalUserMessage(userPrompt, [], { ...(injectActiveEditorContext ? { workspace: workspaceRoot } : {}), activePreview: panel?.activePreview(), + resourceTypeSync: await resolveResourceTypeSync(workspaceRoot, []), }), tools: [ ...getGlobalEvalTools(options.sessionChat ?? false), diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index d8284039b9..69398555f0 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import confluenceFixture from '../../fixtures/frontend/global/hub/confluence.json' import servicenowFixture from '../../fixtures/frontend/global/hub/servicenow.json' import outreachFixture from '../../fixtures/frontend/global/hub/outreach.json' +import syncedResourceTypesFixture from '../../fixtures/frontend/global/hub/synced_resource_types.json' import type { AppWithLastVersion, CompletedJob, @@ -98,6 +99,13 @@ export interface BenchmarkWorkspaceResource { description?: string } +/** A resource type the instance holds. */ +export interface BenchmarkWorkspaceResourceType { + name: string + schema?: unknown + description?: string +} + export interface BenchmarkWorkspaceJob { /** Stable id so a case prompt can reference a specific run (e.g. for get_run). */ id?: string @@ -118,6 +126,8 @@ export interface BenchmarkWorkspaceRunnables { variables?: BenchmarkWorkspaceVariable[] aiProviders?: BenchmarkWorkspaceAiProvider[] resources?: BenchmarkWorkspaceResource[] + /** Unset means an instance synced with the hub. */ + resourceTypes?: BenchmarkWorkspaceResourceType[] datatables?: BenchmarkDatatableSeed[] /** DuckLake catalog names, as `list_ducklakes` reports them. */ ducklakes?: string[] @@ -328,6 +338,57 @@ export function listBenchmarkPlainResources(workspace: string): ListableResource })) } +function benchmarkResourceTypes(workspace: string): BenchmarkWorkspaceResourceType[] { + return benchmarkWorkspaceRunnables.get(workspace)?.resourceTypes ?? syncedResourceTypes() +} + +/** Resource type names of a benchmark workspace, as `ResourceService.listResourceTypeNames` + * returns them. Null when the workspace is not a benchmark one. */ +export function listBenchmarkResourceTypeNames(workspace: string): string[] | null { + if (!benchmarkWorkspaces.has(workspace)) { + return null + } + return benchmarkResourceTypes(workspace).map((rt) => rt.name) +} + +/** Adds a resource type to a benchmark workspace, as `ResourceService.createResourceType` does, + * so later searches in the same case find it. */ +export function createBenchmarkResourceType( + workspace: string, + resourceType: BenchmarkWorkspaceResourceType +): void { + const runnables = benchmarkWorkspaceRunnables.get(workspace) + if (!runnables) { + throw new Error(`Workspace "${workspace}" is not a benchmark workspace`) + } + const existing = benchmarkResourceTypes(workspace) + if (existing.some((rt) => rt.name === resourceType.name)) { + throw new Error(`Resource type ${resourceType.name} already exists`) + } + runnables.resourceTypes = [...existing, resourceType] +} + +/** Stands in for the resource type search: a type matches when a word of the query appears in + * its name or description, ranked by how many do. */ +export function queryBenchmarkResourceTypes( + workspace: string, + text: string, + limit = 10 +): { name: string; schema?: unknown; score: number }[] { + const words = text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length >= 3) + return benchmarkResourceTypes(workspace) + .map((rt) => { + const haystack = `${rt.name} ${rt.description ?? ''}`.toLowerCase() + return { name: rt.name, schema: rt.schema, score: words.filter((w) => haystack.includes(w)).length } + }) + .filter((rt) => rt.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) +} + /** A seeded resource with its value, as `ResourceService.getResource` returns it. Covers both * seed kinds, so it agrees with `existsResource` and `listResource` — both of those report AI * providers too, and a case that lists resources and then reads one by path would otherwise get @@ -1468,6 +1529,42 @@ const BENCHMARK_HUB_INTEGRATION_META: Record< } } +/** The resource type a mocked hub integration takes, as its metadata serves it. */ +function benchmarkHubIntegrationResourceType(app: string): BenchmarkWorkspaceResourceType | null { + const real = REAL_HUB_INTEGRATIONS.find((integration) => integration.app === app) + if (real) { + return real.resource_type + } + const entry = BENCHMARK_HUB_INTEGRATION_META[app] + if (!entry) { + return null + } + return { + name: app, + description: `${entry.display_name} credentials`, + schema: { + type: 'object', + required: ['apiKey'], + properties: { + apiKey: { type: 'string', description: `${entry.display_name} API key` } + } + } + } +} + +/** What a hub-synced instance holds: common hub types, plus the one each mocked hub + * integration takes, so the hub and the instance agree on them. */ +function syncedResourceTypes(): BenchmarkWorkspaceResourceType[] { + const integrations = [ + ...REAL_HUB_INTEGRATIONS.map((integration) => integration.app), + ...Object.keys(BENCHMARK_HUB_INTEGRATION_META) + ] + return [ + ...syncedResourceTypesFixture, + ...integrations.flatMap((app) => benchmarkHubIntegrationResourceType(app) ?? []) + ] +} + /** Mirrors the hub's own derivation: hosts seen in the shipped scripts, whether they * call the provider directly, and how many there are of each kind. */ function benchmarkDerivedFacts(app: string) { @@ -1602,7 +1699,7 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon meta: real.meta, meta_updated_at: null, derived: benchmarkDerivedFacts(app), - resource_types: [{ id: 1, ...real.resource_type }] + resource_types: [{ id: 1, ...benchmarkHubIntegrationResourceType(app) }] }) } const entry = BENCHMARK_HUB_INTEGRATION_META[app] @@ -1619,23 +1716,7 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon meta: entry.meta, meta_updated_at: null, derived: benchmarkDerivedFacts(app), - resource_types: [ - { - id: 1, - name: app, - description: `${entry.display_name} credentials`, - schema: { - type: 'object', - required: ['apiKey'], - properties: { - apiKey: { - type: 'string', - description: `${entry.display_name} API key` - } - } - } - } - ] + resource_types: [{ id: 1, ...benchmarkHubIntegrationResourceType(app) }] }) } if (path === '/api/integrations/hub/list') { diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index cb7469b45e..b24ccbe5e4 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -111,6 +111,9 @@ vi.mock('$lib/gen', async () => { getBenchmarkResource, listBenchmarkAiProviderResources, listBenchmarkPlainResources, + listBenchmarkResourceTypeNames, + queryBenchmarkResourceTypes, + createBenchmarkResourceType, listBenchmarkApps, listBenchmarkDatatables, listBenchmarkDataMetrics, @@ -487,8 +490,23 @@ vi.mock('$lib/gen', async () => { } return value }, - queryResourceTypes: async (data: { workspace: string }) => - hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data) + queryResourceTypes: async (data: { workspace: string; text: string; limit?: number }) => + hasBenchmarkWorkspace(data.workspace) + ? queryBenchmarkResourceTypes(data.workspace, data.text, data.limit) + : actual.ResourceService.queryResourceTypes(data), + listResourceTypeNames: async (data: { workspace: string }) => + listBenchmarkResourceTypeNames(data.workspace) ?? + actual.ResourceService.listResourceTypeNames(data), + createResourceType: async (data: { + workspace: string + requestBody: { name: string; schema?: unknown; description?: string } + }) => { + if (!hasBenchmarkWorkspace(data.workspace)) { + return actual.ResourceService.createResourceType(data) + } + createBenchmarkResourceType(data.workspace, data.requestBody) + return `resource_type ${data.requestBody.name} created` + } }), VariableService: wrapService(actual.VariableService, { existsVariable: async (data: { workspace: string; path: string }) => diff --git a/ai_evals/cases/cli.yaml b/ai_evals/cases/cli.yaml index b7abcf7f14..fdf469130d 100644 --- a/ai_evals/cases/cli.yaml +++ b/ai_evals/cases/cli.yaml @@ -85,6 +85,22 @@ - updates the step code so the returned greeting uses punctuation - defaults punctuation to an exclamation mark when omitted +# An in-house API has no hub resource type, so its connection needs a type of the workspace's +# own: a token parameter or a variable read inside the code is the fallback the guidance rules out. +- id: cli-resource-type-custom-api + prompt: |- + Create a Windmill Bun script at `f/evals/get_invoice.ts` that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token. + cliExpect: + requiredSkills: + - write-script-bun + forbiddenExecutedCommands: + - ^wmill sync push + judgeChecklist: + - creates the script at f/evals/get_invoice.ts, fetching an invoice by id with a bearer token + - the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code + - defines the billing API's resource type with its base URL and token fields, as a resource-type file or in the reply (asking before writing the file is fine) + - the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript, `c_acme_billing.resource-type.json`) + - id: flow-reuse-existing-script prompt: |- There is already a reusable greeting script at `f/lib/format_greeting.ts`. diff --git a/ai_evals/cases/flow.yaml b/ai_evals/cases/flow.yaml index 3a4f203b48..7454a85c9c 100644 --- a/ai_evals/cases/flow.yaml +++ b/ai_evals/cases/flow.yaml @@ -16,6 +16,16 @@ - "the main step is named `sum_numbers`" - the flow returns the sum of the two numbers +# An in-house API has no hub resource type: the connection still comes in as one resource, not +# a token input or a variable read inside the code. +- id: flow-resource-type-custom-api + prompt: |- + Create a flow that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token. + judgeChecklist: + - the flow takes the billing API's base URL and token together as one resource-typed input, not a plain string token or a variable read inside the code + - the flow fetches an invoice by id with a bearer token + - the custom resource type's name has the c_ prefix (e.g. `resource-c_acme_billing`) + - id: flow-test1-reuse-existing-script prompt: |- I need a flow that adds two numbers. diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 4b6704b229..c39acd5fcc 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -759,6 +759,65 @@ - keeps both drafts under a sensible eval/global Slack-related path - does not create schedules, triggers, or deployed workspace changes +# The instance never synced the hub's resource types, so the one Confluence takes is missing: +# the script's connection picker stays empty until a superadmin syncs, which only the +# assistant's reply can tell the user. A type of its own would duplicate the hub's once synced, +# so it is only for a user who says they would rather not sync. +- id: global-resource-type-hub-not-synced + prompt: |- + Write a script that creates a page in our Confluence space from a space key, a title and the page body. + initial: ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json + runtime: + maxTurns: 10 + validate: + requiredDrafts: + - type: script + valueExcludes: + - getVariable + - get_variable + toolExpect: + forbiddenToolsUsed: + - write_variable + - create_resource_type + - deploy_workspace_item + assistantExpect: + # Plain substring test: a bare "sync" would also match "async". + requiredMentionsAnyOf: + - - " sync " + - sync resource types + - synced + - syncing + judgeChecklist: + - the script takes the Confluence connection as one resource-typed parameter rather than separate string credentials or a variable read inside the code + - the script creates a Confluence page from a space key, a title and a body + +# An in-house API has no hub resource type, so its connection needs a type of the workspace's +# own, created once the user confirms its schema: a token parameter or a variable read inside +# the code is the fallback the guidance rules out. +- id: global-resource-type-custom-api + prompt: |- + Write a script that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token. + runtime: + maxTurns: 10 + userAnswers: + - Yes, use a resource type + validate: + requiredDrafts: + - type: script + valueExcludes: + - getVariable + - get_variable + toolExpect: + requiredToolsUsed: + - create_resource_type + forbiddenToolsUsed: + - write_variable + - deploy_workspace_item + judgeChecklist: + - the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code + - the script fetches an invoice by id with a bearer token + - the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript, `resource-c_acme_billing` in the schema) + - id: global-test19-datatable-not-configured-asks-to-set-up prompt: |- Here are two newsletter signups: alice@example.com and bob@example.com. diff --git a/ai_evals/cases/script.yaml b/ai_evals/cases/script.yaml index f0910e939a..c4ed76605b 100644 --- a/ai_evals/cases/script.yaml +++ b/ai_evals/cases/script.yaml @@ -13,6 +13,17 @@ - returns a plain greeting string - does not wrap the result in an object or array +# An in-house API has no hub resource type: the connection still comes in as one resource, not +# a token parameter or a variable read inside the code. +- id: script-resource-type-custom-api + prompt: |- + Update the current Bun script so it fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token. + initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json + judgeChecklist: + - the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code + - the script fetches an invoice by id with a bearer token + - the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript) + - id: script-test2-create-current-script-schedule prompt: |- Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`. diff --git a/ai_evals/fixtures/frontend/global/hub/synced_resource_types.json b/ai_evals/fixtures/frontend/global/hub/synced_resource_types.json new file mode 100644 index 0000000000..033c53ba3e --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/synced_resource_types.json @@ -0,0 +1,893 @@ +[ + { + "name": "ai_instruction", + "description": "Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.", + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + } + }, + { + "name": "ai_skill", + "description": "A reusable instruction set for the AI chat, in the SKILL.md format. The resource description is what the assistant sees when deciding whether the skill applies; the file body is the instructions it follows.", + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + } + }, + { + "name": "airtable", + "description": "Airtable API key or personal access token, sent as a bearer token to the Airtable REST API.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "apiKey": { + "type": "string", + "default": "keyXXXXXXXXXXXXXX", + "description": "" + } + } + } + }, + { + "name": "asana", + "description": "Credentials for the Asana work management API using a personal access token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "aws", + "description": "Amazon Web Services (AWS) credentials: access key ID, secret access key and default region.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "awsAccessKeyId", + "awsSecretAccessKey" + ], + "properties": { + "region": { + "type": "string", + "default": "", + "description": "" + }, + "awsAccessKeyId": { + "type": "string", + "default": "", + "description": "" + }, + "awsSecretAccessKey": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "azure_blob", + "description": "Connection settings for a Microsoft Azure Blob Storage container: account name, container name, access key and endpoint.", + "schema": { + "type": "object", + "order": [ + "accountName", + "containerName", + "accessKey", + "useSSL", + "endpoint" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "accountName", + "containerName", + "useSSL" + ], + "properties": { + "useSSL": { + "type": "boolean", + "default": true, + "description": "" + }, + "endpoint": { + "type": "string", + "default": "", + "description": "" + }, + "accessKey": { + "type": "string", + "default": "", + "description": "" + }, + "accountName": { + "type": "string", + "default": "", + "description": "" + }, + "containerName": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "clickup", + "description": "Credentials for the ClickUp API v2 using a personal API token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "API token retrieved from the dashboard." + } + } + } + }, + { + "name": "discord_webhook", + "description": "Discord incoming webhook URL for posting messages to a Discord channel.\n\nSee: ", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "webhook_url": { + "type": "string", + "description": "The webhook url" + } + } + } + }, + { + "name": "gcal", + "description": "Google OAuth token authorizing the Google Calendar API.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "The OAuth token, refreshed if necessary" + } + } + } + }, + { + "name": "gcp_service_account", + "description": "Google Cloud service account key (JSON) used to authenticate to Google Cloud APIs.", + "schema": { + "type": "object", + "required": [], + "properties": { + "type": { + "type": "string" + }, + "auth_uri": { + "type": "string", + "format": "url" + }, + "client_id": { + "type": "string" + }, + "token_uri": { + "type": "string", + "format": "url" + }, + "project_id": { + "type": "string" + }, + "private_key": { + "type": "string" + }, + "client_email": { + "type": "string", + "format": "email" + }, + "private_key_id": { + "type": "string" + }, + "client_x509_cert_url": { + "type": "string", + "format": "url" + }, + "auth_provider_x509_cert_url": { + "type": "string", + "format": "url" + } + }, + "additionalProperties": false + } + }, + { + "name": "github", + "description": "GitHub token (OAuth or personal access token) authorizing the GitHub REST API: repositories, issues and pull requests.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "" + } + } + } + }, + { + "name": "gitlab", + "description": "Credentials for the GitLab API v4 using a personal access token, with the base URL of the GitLab instance (self-hosted or gitlab.com).", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "" + }, + "baseUrl": { + "type": "string", + "default": "https://gitlab.com", + "description": "" + } + } + } + }, + { + "name": "gmail", + "description": "Google OAuth token authorizing the Gmail API to send email as the connected Google account.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "" + } + } + } + }, + { + "name": "gsheets", + "description": "Google OAuth token authorizing the Google Sheets API for reading and writing spreadsheets.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "The OAuth token, refreshed if necessary" + } + } + } + }, + { + "name": "hubspot", + "description": "Credentials for the HubSpot CRM API using a private app access token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "Access token of a private app" + } + } + } + }, + { + "name": "jira", + "description": "Credentials for the Jira Cloud REST API using basic authentication with an Atlassian site domain.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "username", + "password", + "domain" + ], + "properties": { + "domain": { + "type": "string", + "default": "", + "description": "" + }, + "password": { + "type": "string", + "default": "", + "description": "" + }, + "username": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "linear", + "description": "Credentials for the Linear issue tracking API using a personal API key.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "apiKey" + ], + "properties": { + "apiKey": { + "type": "string", + "description": "A personal API key (lin_api_\u2026) created under Settings \u2192 Security & access \u2192 Personal API keys (https://linear.app/settings/account/security)." + } + } + } + }, + { + "name": "mailchimp", + "description": "API key and account data center prefix (e.g. us12) for the Mailchimp Marketing API.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "server": { + "type": "string", + "default": "", + "description": "The data center for your Mailchimp account. (For example: us12)" + }, + "api_key": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "mongodb", + "description": "Connection settings for a MongoDB database: server host/port list, database name, TLS and SCRAM credentials.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "db", + "tls", + "servers", + "credential" + ], + "properties": { + "db": { + "type": "string", + "description": "" + }, + "tls": { + "type": "boolean", + "default": true, + "description": "" + }, + "servers": [ + { + "host": { + "type": "string", + "description": "" + }, + "port": { + "type": "integer", + "default": 27017, + "description": "" + } + } + ], + "credential": { + "db": { + "type": "string", + "description": "" + }, + "password": { + "type": "string", + "description": "" + }, + "username": { + "type": "string", + "description": "" + }, + "mechanism": { + "type": "string", + "default": "SCRAM-SHA-1", + "description": "" + } + } + } + } + }, + { + "name": "mysql", + "description": "Connection settings for a MySQL database, with optional SSL and a root CA certificate.", + "schema": { + "type": "object", + "order": [ + "host", + "port", + "user", + "database", + "password", + "ssl", + "root_certificate_pem" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "user", + "database", + "password" + ], + "properties": { + "ssl": { + "type": "boolean", + "default": false, + "description": "use ssl" + }, + "host": { + "type": "string", + "default": "", + "description": "instance host" + }, + "port": { + "type": "number", + "default": 3306, + "description": "instance port" + }, + "user": { + "type": "string", + "default": "", + "description": "username" + }, + "database": { + "type": "string", + "default": "", + "description": "database name" + }, + "password": { + "type": "string", + "default": "", + "description": "user's password" + }, + "root_certificate_pem": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "notion", + "description": "Notion API token: filled by Windmill's Notion OAuth connect, or an internal connection's API token / personal access token pasted in.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "Filled automatically when you connect with OAuth. To paste one instead: the API token of an internal connection (ntn_\u2026) or a personal access token, both created in the Notion developer portal at https://www.notion.so/developers. A connection token only reaches the pages granted to it under Content access." + } + } + } + }, + { + "name": "openai", + "description": "OpenAI secret API key, with optional organization ID and base URL override, for the OpenAI API.", + "schema": { + "type": "object", + "order": [ + "api_key", + "organization_id", + "base_url" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "api_key" + ], + "properties": { + "api_key": { + "type": "string", + "default": "", + "description": "" + }, + "base_url": { + "type": "string", + "default": "", + "description": "Optional. Only needed to overwrite the default base URL." + }, + "organization_id": { + "type": "string", + "default": "", + "description": "Only needs to be set for users who belong to multiple organizations and they want to use an organization other than the default one" + } + } + } + }, + { + "name": "postgresql", + "description": "Connection settings for a PostgreSQL database: host, port, database name, user, password and SSL/IAM auth options.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "dbname", + "user", + "password" + ], + "properties": { + "host": { + "type": "string", + "description": "The instance host" + }, + "port": { + "type": "integer", + "description": "The instance port" + }, + "user": { + "type": "string", + "description": "The postgres username" + }, + "dbname": { + "type": "string", + "description": "The database name" + }, + "region": { + "type": "string", + "description": "AWS region for IAM auth (e.g. us-east-1). Falls back to AWS_REGION env var if not set." + }, + "options": { + "type": "string", + "description": "Startup options sent to the server (the libpq options parameter), such as endpoint= for a Neon pooled or private connection, or -c search_path=myschema" + }, + "sslmode": { + "enum": [ + "disable", + "allow", + "prefer", + "require", + "verify-ca", + "verify-full" + ], + "type": "string", + "description": "Most cloud databases (Heroku, AWS, Supabase, etc.) require SSL. Self-hosted databases typically don't." + }, + "password": { + "type": "string", + "description": "The postgres users password" + }, + "use_iam_auth": { + "type": "boolean", + "default": false, + "description": "Use IAM authentication (AWS RDS/Aurora). When enabled, the password field is ignored and a short-lived IAM token is generated instead." + }, + "accept_invalid_certs": { + "type": "boolean", + "default": false, + "showExpr": "fields.sslmode == 'verify-ca' || fields.sslmode == 'verify-full'", + "description": "Accept any certificate presented by the server, which disables TLS certificate verification and is not recommended. Only applies to verify-ca/verify-full." + }, + "root_certificate_pem": { + "type": "string", + "default": "", + "description": "The root certificate in pe format" + } + } + } + }, + { + "name": "s3", + "description": "Connection settings for an Amazon S3 (AWS) bucket: endpoint, region, bucket name and access keys.", + "schema": { + "type": "object", + "order": [ + "endPoint", + "region", + "pathStyle", + "bucket", + "accessKey", + "secretKey", + "port", + "useSSL" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "bucket" + ], + "properties": { + "port": { + "type": "number", + "description": "Deprecated: pass in a complete URL to `endPoint` instead, including a port number if needed" + }, + "bucket": { + "type": "string", + "default": "", + "description": "" + }, + "region": { + "type": "string", + "default": "", + "description": "AWS region (e.g. us-east-1). Used to auto-generate the S3 endpoint URL when endPoint is not specified" + }, + "useSSL": { + "type": "boolean", + "default": true, + "description": "Deprecated: pass in a complete URL to `endPoint` instead, including 'https://' or 'http://'" + }, + "endPoint": { + "type": "string", + "default": "", + "description": "The full URL of the S3 endpoint (e.g. https://s3.us-east-1.amazonaws.com)" + }, + "accessKey": { + "type": "string", + "default": "", + "description": "" + }, + "pathStyle": { + "type": "boolean", + "default": true, + "description": "// instead of ./" + }, + "secretKey": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "salesforce", + "description": "Salesforce OAuth Authorization Code credential. The token is populated by Windmill's OAuth flow; instance_url pins every REST call to the org's data host, api_version selects the REST API version.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token", + "instance_url" + ], + "properties": { + "token": { + "type": "string", + "description": "OAuth access token. Populated by Windmill's OAuth flow." + }, + "api_version": { + "type": "string", + "description": "Salesforce REST API version path segment, e.g. v60.0. Optional \u2014 scripts default to v60.0 when left blank." + }, + "instance_url": { + "type": "string", + "description": "Org data host returned in the OAuth token response, e.g. https://acme.my.salesforce.com (production) or https://acme--sandbox.sandbox.my.salesforce.com (sandbox). Every REST call is made against this host, not login.salesforce.com." + } + } + } + }, + { + "name": "sendgrid", + "description": "Twilio SendGrid API key for the SendGrid v3 API: transactional email, contacts, marketing lists and bounces.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "API token" + } + } + } + }, + { + "name": "shopify", + "description": "Credentials for the Shopify Admin API using a store name and access token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "store_name", + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "" + }, + "store_name": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "slack", + "description": "Slack bot or user OAuth token authorizing the Slack Web API (messages, channels, users and files).", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "token": { + "type": "string", + "description": "The slack token" + } + } + } + }, + { + "name": "smtp", + "description": "Connection settings for an SMTP mail server used to send email.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "host" + ], + "properties": { + "host": { + "type": "string", + "default": "mail.smtpbucket.com", + "description": "SMTP host address" + }, + "port": { + "type": "integer", + "default": 8025, + "description": "port number on which to connect" + }, + "user": { + "type": "string", + "description": "" + }, + "password": { + "type": "string", + "description": "" + } + } + } + }, + { + "name": "stripe", + "description": "Stripe secret API key for the Stripe payments API.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "token": { + "type": "string", + "description": "" + } + } + } + }, + { + "name": "supabase", + "description": "Supabase project URL and API key for the Supabase REST, auth and storage APIs.\n\nRetrieve both values in \"Project Settings -> API\".", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "key": { + "type": "string", + "default": "", + "description": "The unique Supabase Key which is supplied when you create a new project in your project dashboard." + }, + "url": { + "type": "string", + "default": "", + "description": "The unique Supabase URL which is supplied when you create a new project in your project dashboard." + } + } + } + }, + { + "name": "twilio", + "description": "Credentials for the Twilio messaging and voice API using an Account SID and auth token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token", + "accountSid" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "" + }, + "accountSid": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "zendesk", + "description": "Zendesk API credentials: account subdomain plus email and password or API token, used with HTTP Basic auth.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "username", + "subdomain", + "password" + ], + "properties": { + "password": { + "type": "string", + "default": "", + "description": "password or api token" + }, + "username": { + "type": "string", + "default": "", + "description": "email or \"{email}/token\" if using an api token" + }, + "subdomain": { + "type": "string", + "default": "", + "description": "" + } + } + } + } +] diff --git a/ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json b/ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json new file mode 100644 index 0000000000..8e0cb5a54d --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json @@ -0,0 +1,5 @@ +{ + "workspace": { + "resourceTypes": [{ "name": "ai_skill" }, { "name": "ai_instruction" }] + } +} diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index 505200f07a..e8e03757e5 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -91,6 +91,19 @@ async function push(opts: PushOptions, filePath: string, name: string) { log.info(colors.bold.underline.green("Resource pushed")); } +// The hub publishes hundreds of resource types and an instance seeds only a few of its own, +// so fewer than this means the instance never synced with the hub. +const UNSYNCED_RESOURCE_TYPE_COUNT = 20; + +function warnIfNotSyncedWithHub(resourceTypes: ResourceType[]) { + const count = new Set(resourceTypes.map((rt) => rt.name)).size; + if (count < UNSYNCED_RESOURCE_TYPE_COUNT) { + log.warnAlways( + `Only ${count} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. A superadmin can sync resource types from the hub with "Sync resource types with Hub" in the Add resource dialog of the Resources page.` + ); + } +} + async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean }) { if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); @@ -99,12 +112,9 @@ async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean }) workspace: workspace.workspaceId, }); + warnIfNotSyncedWithHub(res); if (opts.json) { console.log(JSON.stringify(res)); - } else if (res.length === 0) { - log.info("No custom resource types found in this workspace."); - log.info("Built-in types like 'postgresql', 'slack', 'mysql', etc. are available from the Windmill Hub."); - return; } else if (opts.schema) { new Table() .header(["Workspace", "Name", "Schema"]) @@ -172,6 +182,7 @@ export async function generateRTNamespace(opts: GlobalOptions) { const rts = await wmill.listResourceType({ workspace: workspace.workspaceId, }); + warnIfNotSyncedWithHub(rts); let namespaceContent = "declare namespace RT {\n"; namespaceContent += rts diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 85848f43aa..3d5b9550ee 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -96,6 +96,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -260,6 +263,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -384,6 +390,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -501,6 +510,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -1335,6 +1347,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -2181,6 +2196,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -2297,6 +2315,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -3133,6 +3154,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -3283,6 +3307,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -3416,6 +3443,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -3536,6 +3566,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -3649,6 +3682,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -3765,6 +3801,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -3882,6 +3921,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -4014,6 +4056,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -4129,6 +4174,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -4259,6 +4307,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -5285,6 +5336,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -5445,6 +5499,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -5595,6 +5652,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 @@ -6355,6 +6415,14 @@ In the flow schema, set the property type to \`"object"\` with format \`"resourc } \`\`\` +### When No Resource Type Fits + +A connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input \`resource-c_\` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The \`c_\` prefix of custom types keeps one apart from a hub type synced later under the same name. + +- If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. +- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using \`$res:\` prefix: @@ -7276,13 +7344,23 @@ The prefix must be on the string itself. } \`\`\` +## When No Resource Type Fits + +The Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service: + +- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both. +- Otherwise, or when the user prefers it to syncing, propose a custom resource type named \`c_\` holding the service's connection fields (base URL, account or region identifiers, credentials), as in "Custom Resource Types" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields. +- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references. + ## Custom Resource Types +A custom type's name starts with \`c_\`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: \`format: resource-c_custom_api\`, \`RT.CCustomApi\` in TypeScript, a \`c_custom_api\` TypedDict in Python. + Create custom resource types with JSON Schema: \`\`\`json { - "name": "custom_api", + "name": "c_custom_api", "schema": { "type": "object", "properties": { @@ -7296,7 +7374,7 @@ Create custom resource types with JSON Schema: } \`\`\` -Save as: \`custom_api.resource-type.json\` +Save as: \`c_custom_api.resource-type.json\` ## OAuth Resources diff --git a/cli/test/standalone_commands.test.ts b/cli/test/standalone_commands.test.ts index 271b13867b..561ccf9d42 100644 --- a/cli/test/standalone_commands.test.ts +++ b/cli/test/standalone_commands.test.ts @@ -139,10 +139,7 @@ describe("resource-type commands", () => { ); expect(result.code).toEqual(0); - // When empty, shows helpful message; when populated, shows table with Name header - const hasTable = result.stdout.includes("Name"); - const hasEmptyMessage = result.stdout.includes("No custom resource types"); - expect(hasTable || hasEmptyMessage).toBe(true); + expect(result.stdout).toContain("Name"); }); }); diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6c49effb81..556f555897 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -49,7 +49,11 @@ import { import type { ScriptLintResult } from './shared' import { navigatorTools, prepareNavigatorSystemMessage } from './navigator/core' import { loadApiTools } from './api/apiTools' -import { prepareScriptUserMessage } from './script/core' +import { + prepareScriptUserMessage, + resolveResourceTypeSync, + type ResourceTypeSync +} from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' import { workspaceAIClients, getStreamedCompletionText } from '../lib' @@ -2680,6 +2684,9 @@ export class AIChatManager implements ChatViewHost { this.systemMessage = { ...target, content: `${target.content}\n\n${section}` } } + /** Resolved per send with the rest of the pre-flight; see `resolveResourceTypeSync`. */ + resourceTypeSync: ResourceTypeSync | undefined = undefined + // Same shape as refreshGlobalSkills. Re-resolved per send rather than once for the // session's life: the operating workspace can change between sends, and a transient // failure resolves fail-open, so the next message re-asks rather than keeping that answer. @@ -3186,7 +3193,8 @@ export class AIChatManager implements ChatViewHost { this.contextManager.getSelectedContext(), { workspace: this.operatingWorkspace, - activePreview: this.activePreviewResolver?.() + activePreview: this.activePreviewResolver?.(), + resourceTypeSync: this.resourceTypeSync } ) } @@ -3716,7 +3724,10 @@ export class AIChatManager implements ChatViewHost { this.refreshGlobalSkills(this.operatingWorkspace ?? ''), this.refreshGlobalFolderInstructions(this.operatingWorkspace ?? ''), this.refreshMcpServers(this.operatingWorkspace ?? ''), - this.refreshSessionAccess(this.operatingWorkspace ?? '') + this.refreshSessionAccess(this.operatingWorkspace ?? ''), + resolveResourceTypeSync(this.operatingWorkspace ?? '', this.messages).then((sync) => { + this.resourceTypeSync = sync + }) ]) } // Stop/Escape during the beforeSend pre-flight aborted this send before any @@ -4006,7 +4017,8 @@ export class AIChatManager implements ChatViewHost { workspace: this.operatingWorkspace, activePreview: this.activePreviewResolver?.(), images: sentImages, - files: files + files: files, + resourceTypeSync: this.resourceTypeSync }) break case AIMode.APP: diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index d510b21826..69dfbcc265 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -241,6 +241,7 @@ vi.mock('$lib/gen', async () => { throw new Error('getResource mock not configured') }), createResource: vi.fn(async () => 'created'), + createResourceType: vi.fn(async () => 'created'), updateResource: vi.fn(async () => 'updated'), deleteResource: vi.fn(async () => 'deleted'), // A workspace with no skills, which is what makes `read_skill` refuse a path @@ -415,6 +416,7 @@ import { import { devopsRole, superadmin, userStore, usersWorkspaceStore } from '$lib/stores' import { processSecretArgs } from '$lib/components/secretArgUtils' import { clearWorkspaceRoleCache } from '$lib/user' +import { resourceTypesStore } from '$lib/components/resourceTypesStore' import { get } from 'svelte/store' import type { Tool, ToolCallbacks } from '../shared' @@ -8147,3 +8149,27 @@ describe('global prompt identity', () => { expect(get(userStore)?.folders).toEqual(['alice_stuff']) }) }) + +describe('create_resource_type', () => { + // The Add resource type form prefixes custom types with c_ unless an admin turns it off, so a + // hub type synced later under the same name cannot clash with one the chat created. + it.each([ + ['acme_billing', 'c_acme_billing'], + ['c_acme_billing', 'c_acme_billing'] + ])('creates %s as %s', async (given, created) => { + vi.mocked(ResourceService.createResourceType).mockClear() + resourceTypesStore.set(['postgresql']) + + const result = await callGlobalTool('create_resource_type', { + name: given, + description: 'Acme billing', + schema: { type: 'object' } + }) + + expect(vi.mocked(ResourceService.createResourceType).mock.calls[0][0].requestBody.name).toBe( + created + ) + expect(result).toContain(created) + expect(get(resourceTypesStore)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 3b70b2b90b..f27526b6f5 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -109,7 +109,12 @@ import { createInlineScriptSession, findUnresolvedInlineScriptRefs } from '../flow/inlineScriptsUtils' -import { searchNpmPackagesTool } from '../script/core' +import { + resourceTypeSyncBlock, + searchNpmPackagesTool, + searchResourceTypes, + type ResourceTypeSync +} from '../script/core' import type { McpServer } from './mcpTools' import { logFeatureUsage } from '$lib/utils/featureUsage' import { isSkillEnabled } from '../skills/enabledSkills' @@ -204,6 +209,7 @@ import { } from '$lib/stores' import { getWorkspaceRole, type RoleLookup } from '$lib/user' import { refreshSuperadmin } from '$lib/refreshUser' +import { resourceTypesStore } from '$lib/components/resourceTypesStore' import { get } from 'svelte/store' import { canonicalDraftSideValue, @@ -360,6 +366,8 @@ export type GlobalUserMessageOptions = { /** Text files attached to this message; listed by reference below — the model * reads their content on demand via the file tools. */ files?: AttachedTextFile[] + /** From `resolveResourceTypeSync`; absent on a synced instance, which is told nothing. */ + resourceTypeSync?: ResourceTypeSync } const itemTypeSchema = z.enum(ITEM_TYPES) @@ -735,6 +743,20 @@ const searchResourceTypesSchema = z.object({ .describe('Max number of resource types to return. Defaults to 5.') }) +const createResourceTypeSchema = z.object({ + name: z + .string() + .describe( + 'Type name with the c_ prefix of custom types, e.g. c_acme_billing (added when missing): 1 to 50 letters, digits, "_" or "-". Resources and resource-typed parameters refer to it by this name.' + ), + description: z.string().describe('What a resource of this type connects to.'), + schema: z + .record(z.string(), z.any()) + .describe( + 'JSON Schema of a resource value of this type: { "type": "object", "properties": {...}, "required": [...] }, with every connection field, secrets included (a resource fills those with "$var:" references).' + ) +}) + const getRunSchema = z.object({ id: z.string().describe('The UUID of the run (job) to inspect.'), step: z @@ -1459,8 +1481,10 @@ const buildGlobalSystemPrompt = ( const canRunPreview = !access || access.has('run_preview') const canManageSchedules = !access || access.has('manage_schedules') const canManageTriggers = !access || access.has('manage_triggers') - // The deploy tools take their kind as an argument, so `deploy` gates only the folder text. + // The deploy tools take their kind as an argument, so `deploy` gates only the folder and + // resource type text. const canCreateFolder = !access || access.has('deploy') + const canCreateResourceType = !access || access.has('deploy') // Each gated block carries its own leading newline, so dropping one leaves no blank // line behind and a full-access prompt is byte-for-byte the ungated text. const when = (cond: boolean, block: string) => (cond ? block : '') @@ -1573,7 +1597,7 @@ Rules:${when( - Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub///\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing. - Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration \`documented\` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API. - If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself. -${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. +${when(canCreateResourceType, '- create_resource_type creates a resource type the workspace lacks. The user confirms the schema in the call itself, so that is where you propose it rather than asking first in chat.\n')}${when(!canCreateResourceType, '- This user cannot create a resource type here, in chat or on the Resources page, since the workspace refuses their direct deployment: when one is needed, give them its name and JSON Schema to pass to a workspace admin, who can add it.\n')}${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. ${pipelineBullet}` )}${when( canRunPreview && canWriteDraft, @@ -4486,33 +4510,74 @@ export const globalTools: SessionTool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: `Searching resource types for "${parsed.query}"...` }) - const results = await ResourceService.queryResourceTypes({ + const { resourceTypes, note } = await searchResourceTypes( + parsed.query, workspace, - text: parsed.query, - limit: parsed.limit ?? 5 - }) + parsed.limit ?? 5 + ) toolCallbacks.setToolStatus(toolId, { - content: `Found ${results.length} resource type(s) for "${parsed.query}"` + content: `Found ${resourceTypes.length} resource type(s) for "${parsed.query}"` }) const listing = JSON.stringify( - results.map((rt) => ({ + resourceTypes.map((rt) => ({ name: rt.name, schema: rt.schema })), null, 2 ) + // On an unsynced instance the dedicated type is only missing until a sync, so its note + // replaces the "no dedicated type" one, which would have the model create a duplicate. + if (note) { + return `${listing}\n\n${note}` + } // The search is semantic and always returns its closest types, so a miss reads // like a hit unless the absence is spelled out. const words = (parsed.query.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter( (w) => w.length >= 3 ) - const named = results.some((rt) => words.some((w) => rt.name.toLowerCase().includes(w))) + const named = resourceTypes.some((rt) => words.some((w) => rt.name.toLowerCase().includes(w))) return named || words.length === 0 ? listing : `${listing}\n\nNo resource type name contains "${parsed.query}": these are only the nearest matches, so no dedicated type exists. Searching again with synonyms will not find one — use a fitting generic type above, or create a custom c_ type.` } }, + { + // Not a draft: resource types have no draft kind, so this writes the workspace once the + // user confirms, under the same right as a deploy. + requires: DEPLOY, + def: createToolDef( + createResourceTypeSchema, + 'create_resource_type', + 'Create a resource type in the workspace. Mutates the workspace once the user confirms the schema.', + { strict: false } + ), + showDetails: true, + requiresConfirmation: true, + confirmationMessage: 'Create resource type', + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = createResourceTypeSchema.parse(args) + // The prefix the Add resource type form adds unless an admin turns it off: a hub type + // of the same name, synced later, would otherwise clash with this one. + const name = parsed.name.startsWith('c_') ? parsed.name : `c_${parsed.name}` + await ResourceService.createResourceType({ + workspace, + requestBody: { + name, + description: parsed.description, + schema: parsed.schema + } + }) + // Forms show a resource picker only for a type in this cached list, so a script + // written against the new type would otherwise get a plain object input. + resourceTypesStore.set(undefined) + toolCallbacks.setToolStatus(toolId, { content: `Created resource type ${name}` }) + return JSON.stringify({ + success: true, + message: `Created resource type ${name}. Refer to it by this name.` + }) + } + }, createDbSchemaTool<{}>({ description: 'Fetch the schema (tables and columns) of a database resource by its path. Supports postgresql, mysql, ms_sql_server, snowflake and bigquery resources.', @@ -9290,6 +9355,10 @@ export function prepareGlobalUserMessage( content += '\n' } + if (options.resourceTypeSync) { + content += resourceTypeSyncBlock(options.resourceTypeSync) + } + content += `## INSTRUCTIONS:\n${instructions}` const images = options.images ?? [] diff --git a/frontend/src/lib/components/copilot/chat/script/core.test.ts b/frontend/src/lib/components/copilot/chat/script/core.test.ts index f235ae3796..2a6c37ca71 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.test.ts @@ -14,7 +14,50 @@ vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () = vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) vi.mock('$lib/components/vscode', () => ({})) -import { editCodeToolWithDiff } from './core' +import { ResourceService } from '$lib/gen' +import { + editCodeToolWithDiff, + resolveResourceTypeSync, + resourceTypeSyncBlock, + searchResourceTypes +} from './core' + +describe('searchResourceTypes', () => { + it.each([ + [['ai_skill', 'ai_instruction'], true], + [Array.from({ length: 30 }, (_, i) => `type_${i}`), false] + ])('notes only an instance that never synced with the hub (%#)', async (names, noted) => { + vi.spyOn(ResourceService, 'queryResourceTypes').mockResolvedValue([ + { name: 'ai_skill', score: 0.8 } + ]) + vi.spyOn(ResourceService, 'listResourceTypeNames').mockResolvedValue(names) + + const { note } = await searchResourceTypes('stripe', 'ws', 5) + + expect(note?.includes('never synced') ?? false).toBe(noted) + }) +}) + +// The transition is what keeps the note out of the system prompt: a conversation that was +// told "not synced" has to be told once when that stops being true, and nobody else is. +describe('resolveResourceTypeSync', () => { + const synced = Array.from({ length: 30 }, (_, i) => `type_${i}`) + const told = (state: 'unsynced' | 'now_synced') => ({ + role: 'user', + content: resourceTypeSyncBlock(state) + }) + + it.each([ + ['unsynced instance', ['ai_skill'], [], 'unsynced'], + ['always synced', synced, [], undefined], + ['synced after an unsynced note', synced, [told('unsynced')], 'now_synced'], + ['already told it synced', synced, [told('unsynced'), told('now_synced')], undefined] + ] as const)('%s', async (label, names, history, expected) => { + vi.spyOn(ResourceService, 'listResourceTypeNames').mockResolvedValue([...names]) + + expect(await resolveResourceTypeSync(`ws-${label}`, history)).toBe(expected) + }) +}) describe('editCodeToolWithDiff', () => { it('records the complete script diff after applying replacements', async () => { diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 92cb889b24..b732e00c05 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -1,4 +1,4 @@ -import { ResourceService, JobService } from '$lib/gen/services.gen' +import { ResourceService, JobService } from '$lib/gen' import type { AIProvider, AIProviderModel, ResourceType, ScriptLang } from '$lib/gen/types.gen' import { capitalize, toCamel } from '$lib/utils' import { compile, phpCompile, pythonCompile } from '../../utils' @@ -19,7 +19,8 @@ import { type ScriptLintResult, formatScriptLintResult, createSearchWorkspaceTool, - createGetRunnableDetailsTool + createGetRunnableDetailsTool, + HUB_SYNC_INSTRUCTIONS } from '../shared' import { createWorkspaceMutationTools } from '../workspaceTools' import { setupTypeAcquisition, type DepsToGet } from '$lib/ata' @@ -41,7 +42,7 @@ const TYPES_CONTEXT_PERCENTAGE = 1 export const DIFF_BASED_EDIT_PROVIDERS: AIProvider[] = [] export function formatResourceTypes( - allResourceTypes: ResourceType[], + allResourceTypes: Pick[], lang: 'python3' | 'php' | 'bun' | 'deno' | 'nativets' | 'bunnative' ) { if (lang === 'python3') { @@ -67,14 +68,75 @@ export function formatResourceTypes( } } -async function getResourceTypes(prompt: string, workspace: string) { - const resourceTypes = await ResourceService.queryResourceTypes({ - workspace: workspace, - text: prompt, - limit: 5 - }) +// The hub publishes hundreds of resource types and an instance seeds only a few of its own, +// so fewer than this means the instance never synced with the hub. +const UNSYNCED_RESOURCE_TYPE_COUNT = 20 - return resourceTypes +/** + * The note on an unsynced instance cannot wait for an empty result: the similarity search + * still returns the nearest of the few types there are (the instance's own `ai_skill` scores + * about 0.8 for "confluence"). + */ +export async function searchResourceTypes( + query: string, + workspace: string, + limit: number +): Promise<{ resourceTypes: Pick[]; note?: string }> { + const [resourceTypes, names] = await Promise.all([ + ResourceService.queryResourceTypes({ workspace, text: query, limit }), + ResourceService.listResourceTypeNames({ workspace }).catch(() => []) + ]) + const count = new Set(names).size + return { + resourceTypes, + note: + count > 0 && count < UNSYNCED_RESOURCE_TYPE_COUNT + ? `Only ${count} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. ${HUB_SYNC_INSTRUCTIONS}` + : undefined + } +} + +export type ResourceTypeSync = 'unsynced' | 'now_synced' + +const NOT_SYNCED_HEADING = '## RESOURCE TYPES NOT SYNCED' +const SYNCED_HEADING = '## RESOURCE TYPES SYNCED' + +// An instance does not go back to unsynced, so one seen synced is never asked again. +const syncedWorkspaces = new Set() + +/** + * Whether the next user message has to say the instance lacks the hub's types, or that an + * earlier such note no longer holds. It rides in the user message, not the system prompt, + * so a sync during a conversation leaves the cached prefix untouched; `history` is what + * tells a sync that happened mid-conversation from an instance that was always synced. + */ +export async function resolveResourceTypeSync( + workspace: string, + history: readonly { role: string; content?: unknown }[] +): Promise { + if (!syncedWorkspaces.has(workspace)) { + const count = await ResourceService.listResourceTypeNames({ workspace }) + .then((names) => new Set(names).size) + .catch(() => 0) + if (count === 0) { + return undefined + } + if (count < UNSYNCED_RESOURCE_TYPE_COUNT) { + return 'unsynced' + } + syncedWorkspaces.add(workspace) + } + const lastNote = history + .filter((m) => m.role === 'user') + .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''))) + .findLast((text) => text.includes(NOT_SYNCED_HEADING) || text.includes(SYNCED_HEADING)) + return lastNote?.includes(NOT_SYNCED_HEADING) ? 'now_synced' : undefined +} + +export function resourceTypeSyncBlock(state: ResourceTypeSync): string { + return state === 'unsynced' + ? `${NOT_SYNCED_HEADING}\nThis instance has not synced the Windmill Hub's resource types, so no hub type exists here yet (stripe, slack, postgresql, ...), including the one a hub script takes. ${HUB_SYNC_INSTRUCTIONS}\n\n` + : `${SYNCED_HEADING}\nThe instance now holds the Windmill Hub's resource types: the earlier "not synced" note no longer applies.\n\n` } export const SUPPORTED_CHAT_SCRIPT_LANGUAGES = [ @@ -149,13 +211,14 @@ export async function getFormattedResourceTypes( case 'bunnative': case 'python3': case 'php': { - const resourceTypes = await getResourceTypes(prompt, workspace) + const { resourceTypes, note } = await searchResourceTypes(prompt, workspace, 5) - const intro = `RESOURCE_TYPES:\n` + const found = + resourceTypes.length > 0 + ? `RESOURCE_TYPES:\n${formatResourceTypes(resourceTypes, lang)}` + : `No resource type found for "${prompt}".` - const resourceTypesText = formatResourceTypes(resourceTypes, lang) - - return intro + resourceTypesText + return note ? `${found}\n\n${note}` : found } default: return '' diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index f346b4297d..9aef408e5a 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -1728,6 +1728,9 @@ function parseResourceTypeSchema(schema: unknown): unknown { } } +export const HUB_SYNC_INSTRUCTIONS = + 'Tell the user: no resource of a missing type can be created until a superadmin syncs resource types from the hub, with "Sync resource types with Hub" in the Add resource dialog of the Resources page.' + export const getHubIntegrationTool = { requires: NONE, def: getHubIntegrationToolDef, diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index e7924a1813..2cc40cd3cc 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -9,6 +9,7 @@ describe('toolReloadEffect', () => { expect(toolReloadEffect('write_resource', {}).pages).toEqual(['/resources']) expect(toolReloadEffect('write_variable', {}).pages).toEqual(['/variables']) expect(toolReloadEffect('create_folder', { name: 'f' }).pages).toEqual(['/folders']) + expect(toolReloadEffect('create_resource_type', { name: 'c_x' }).pages).toEqual(['/resources']) }) it('maps a trigger write to its kind-specific page', () => { diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 6d22309ee1..1de6ad6951 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -49,6 +49,8 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect { return withItem(['/variables'], itemRef('variable', args)) case 'create_folder': return { pages: ['/folders'], items: [], deployed: [] } + case 'create_resource_type': + return { pages: ['/resources'], items: [], deployed: [] } // These two change what is deployed at a path, so a tab on that item's View side // is showing a version that no longer exists. case 'delete_workspace_item': diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index 747f7dc715..d528f36ac5 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -586,6 +586,14 @@ In the flow schema, set the property type to `"object"` with format `"resource-{ } ``` +### When No Resource Type Fits + +A connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input `resource-c_` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The `c_` prefix of custom types keeps one apart from a hub type synced later under the same name. + +- If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. +- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using `$res:` prefix: diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 3667cf5e56..a7ea764689 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -5,6 +5,8 @@ export const SCRIPT_BASE = `# Windmill Script Writing Guide ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named \`c_\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C\` in TypeScript, a \`c_\` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (\`type Stripe = { token: string }\`), which is still the \`stripe\` resource type; in code you write, name it the way the language section shows (\`RT.Stripe\` in TypeScript) rather than copying the inline declaration - 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 @@ -613,6 +615,14 @@ In the flow schema, set the property type to \`"object"\` with format \`"resourc } \`\`\` +### When No Resource Type Fits + +A connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input \`resource-c_\` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The \`c_\` prefix of custom types keeps one apart from a hub type synced later under the same name. + +- If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. +- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using \`$res:\` prefix: @@ -831,13 +841,23 @@ The prefix must be on the string itself. } \`\`\` +## When No Resource Type Fits + +The Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service: + +- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both. +- Otherwise, or when the user prefers it to syncing, propose a custom resource type named \`c_\` holding the service's connection fields (base URL, account or region identifiers, credentials), as in "Custom Resource Types" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields. +- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references. + ## Custom Resource Types +A custom type's name starts with \`c_\`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: \`format: resource-c_custom_api\`, \`RT.CCustomApi\` in TypeScript, a \`c_custom_api\` TypedDict in Python. + Create custom resource types with JSON Schema: \`\`\`json { - "name": "custom_api", + "name": "c_custom_api", "schema": { "type": "object", "properties": { @@ -851,7 +871,7 @@ Create custom resource types with JSON Schema: } \`\`\` -Save as: \`custom_api.resource-type.json\` +Save as: \`c_custom_api.resource-type.json\` ## OAuth Resources diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index d178ddb6c0..8271a528b4 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -3,6 +3,8 @@ ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration - 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 diff --git a/system_prompts/auto-generated/skills/resources/SKILL.md b/system_prompts/auto-generated/skills/resources/SKILL.md index bd690b1dec..62d021f6bf 100644 --- a/system_prompts/auto-generated/skills/resources/SKILL.md +++ b/system_prompts/auto-generated/skills/resources/SKILL.md @@ -214,13 +214,23 @@ The prefix must be on the string itself. } ``` +## When No Resource Type Fits + +The Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service: + +- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both. +- Otherwise, or when the user prefers it to syncing, propose a custom resource type named `c_` holding the service's connection fields (base URL, account or region identifiers, credentials), as in "Custom Resource Types" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields. +- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references. + ## Custom Resource Types +A custom type's name starts with `c_`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: `format: resource-c_custom_api`, `RT.CCustomApi` in TypeScript, a `c_custom_api` TypedDict in Python. + Create custom resource types with JSON Schema: ```json { - "name": "custom_api", + "name": "c_custom_api", "schema": { "type": "object", "properties": { @@ -234,7 +244,7 @@ Create custom resource types with JSON Schema: } ``` -Save as: `custom_api.resource-type.json` +Save as: `c_custom_api.resource-type.json` ## OAuth Resources diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 4ede1e815f..69f30bcfb5 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -675,6 +675,14 @@ In the flow schema, set the property type to `"object"` with format `"resource-{ } ``` +### When No Resource Type Fits + +A connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input `resource-c_` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The `c_` prefix of custom types keeps one apart from a hub type synced later under the same name. + +- If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. +- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using `$res:` prefix: 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 9bb87cb4a1..e67e8fc30c 100644 --- a/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 0c4634b775..c1e9274a7a 100644 --- a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 497336b001..97cf31976e 100644 --- a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 055b1613b2..9b31261bb8 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 beaf7817bb..cc93c352c4 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 389c683f7c..2759e58b26 100644 --- a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 afefb7e987..8325315dfe 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 2ffcf4607c..471a22217a 100644 --- a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 1707fcda4c..4573fa816a 100644 --- a/system_prompts/auto-generated/skills/write-script-go/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-go/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 c928dea3bc..225c0e928f 100644 --- a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 a768a5faa5..8712ee27c3 100644 --- a/system_prompts/auto-generated/skills/write-script-java/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-java/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 9215f06906..195aff374b 100644 --- a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 d682cce351..8acf52e23a 100644 --- a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 16c68834dc..2ed0b477ea 100644 --- a/system_prompts/auto-generated/skills/write-script-php/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-php/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 b0e14a6b12..f98cffd2b6 100644 --- a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 540776c68a..f60d0b531d 100644 --- a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 ee50b6d62d..c12c44e4f0 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 061e1dcc45..636bc100e3 100644 --- a/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 d9b599dffa..2b9280aa46 100644 --- a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 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 cd79a1f5d4..3e38ebc728 100644 --- a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index f6734afe58..80a83e948a 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -602,6 +602,14 @@ In the flow schema, set the property type to `"object"` with format `"resource-{ } ``` +### When No Resource Type Fits + +A connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input `resource-c_` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The `c_` prefix of custom types keeps one apart from a hub type synced later under the same name. + +- If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. +- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using `$res:` prefix: diff --git a/system_prompts/base/resources.md b/system_prompts/base/resources.md index 2929051a03..72d817a1b2 100644 --- a/system_prompts/base/resources.md +++ b/system_prompts/base/resources.md @@ -213,13 +213,23 @@ The prefix must be on the string itself. } ``` +## When No Resource Type Fits + +The Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service: + +- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both. +- Otherwise, or when the user prefers it to syncing, propose a custom resource type named `c_` holding the service's connection fields (base URL, account or region identifiers, credentials), as in "Custom Resource Types" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields. +- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references. + ## Custom Resource Types +A custom type's name starts with `c_`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: `format: resource-c_custom_api`, `RT.CCustomApi` in TypeScript, a `c_custom_api` TypedDict in Python. + Create custom resource types with JSON Schema: ```json { - "name": "custom_api", + "name": "c_custom_api", "schema": { "type": "object", "properties": { @@ -233,7 +243,7 @@ Create custom resource types with JSON Schema: } ``` -Save as: `custom_api.resource-type.json` +Save as: `c_custom_api.resource-type.json` ## OAuth Resources diff --git a/system_prompts/base/script-base.md b/system_prompts/base/script-base.md index 9bd878ea9b..0a9f313228 100644 --- a/system_prompts/base/script-base.md +++ b/system_prompts/base/script-base.md @@ -3,6 +3,11 @@ ## General Principles - A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it +- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration + +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's 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 From 694ed7a0eba255ef42e6cfe988ea3b8c5784dfa1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 14 Oct 2026 10:39:56 +0200 Subject: [PATCH 04/49] fix: keep applied migrations and db settings in data table copies (#11753) * fix: keep applied migrations and db settings in data table copies Co-Authored-By: Claude Opus 5.5 * fix: scope copied db settings per transaction and send timestamps as iso Co-Authored-By: Claude Opus 5.5 * fix: copy migrations and settings only into a fresh import target Co-Authored-By: Claude Opus 5.5 * ci: install the latest pg_dump for backend integration tests Co-Authored-By: Claude Opus 5.5 * ci: enable pgdg so the latest pg_dump is installed Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/backend-test.yml | 8 +- backend/tests/datatable_fork_migrations.rs | 135 ++++++++++++++++++ .../tests/datatable_roles.rs | 4 +- .../src/datatable_clone.rs | 34 +++-- .../src/datatable_migrations.rs | 121 ++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 112 +++++++++++++++ 6 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 backend/tests/datatable_fork_migrations.rs diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 73f955131e..7a045ad70a 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -83,9 +83,13 @@ jobs: chmod +x "$HOME/.local/bin/wmill" echo "$HOME/.local/bin" >> $GITHUB_PATH working-directory: / - - name: Install PowerShell, mold and clang + - name: Install PowerShell, mold, clang and the latest pg_dump + # PGDG's `postgresql-client` is its newest major, Ubuntu's is not: the `postgres` service + # image floats, and pg_dump refuses a server newer than itself. run: | - sudo apt-get update && sudo apt-get install -y powershell mold clang libcurl4-openssl-dev + sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y + sudo apt-get update && sudo apt-get install -y powershell mold clang libcurl4-openssl-dev postgresql-client + pg_dump --version working-directory: / - uses: actions-rust-lang/setup-rust-toolchain@v1 with: diff --git a/backend/tests/datatable_fork_migrations.rs b/backend/tests/datatable_fork_migrations.rs new file mode 100644 index 0000000000..43f43c1cd9 --- /dev/null +++ b/backend/tests/datatable_fork_migrations.rs @@ -0,0 +1,135 @@ +//! A fork's copy of a data table must report the migrations its schema already holds as applied. +//! +//! A schema-only copy restores `_wm_migrations` without its rows, and no copy carries the source's +//! `ALTER DATABASE ... SET search_path`, through which the bookkeeping table is found. Either one +//! alone makes the fork list every applied migration as pending, and running them fails on +//! objects that already exist. The source's `DateStyle` differs from the copy's while the rows +//! are written, so they must not travel in its text form. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const ROLE: &str = "wm_dtfork_test_role"; +const ROLE_PASSWORD: &str = "wm_dtfork_test_pwd"; + +fn authed(b: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + b.header("Authorization", "Bearer DTMIG_ADMIN_TOKEN") +} + +#[sqlx::test(fixtures("datatable_migrations_grants"))] +async fn a_forked_copy_keeps_its_applied_migrations(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let opts = (*db.connect_options()).clone(); + // Databases are cluster-wide, so both are named for this run alone. + let suffix = format!( + "{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + ); + let source = format!("wm_dtfork_src_{suffix}"); + let fork_id = format!("wm-fork-dtfork-{suffix}"); + let copy = format!("wm_fork_dtfork_{suffix}__main"); + + sqlx::query(&format!( + "DO $$ BEGIN \ + CREATE ROLE {ROLE} LOGIN CREATEDB PASSWORD '{ROLE_PASSWORD}'; \ + EXCEPTION WHEN duplicate_object OR unique_violation THEN NULL; \ + END $$" + )) + .execute(&db) + .await?; + sqlx::query(&format!("CREATE DATABASE {source} OWNER {ROLE}")) + .execute(&db) + .await?; + + let result = async { + let source_pool = sqlx::PgPool::connect_with(opts.clone().database(&source)).await?; + sqlx::raw_sql(&format!( + "SET ROLE {ROLE}; \ + CREATE SCHEMA app; \ + CREATE TABLE app.foo (id int); \ + CREATE TABLE app._wm_migrations (datatable TEXT NOT NULL, version BIGINT NOT NULL, \ + installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (datatable, version)); \ + INSERT INTO app._wm_migrations (datatable, version, installed_at) \ + VALUES ('main', 1, '2026-01-31 10:00:00+00'); \ + ALTER DATABASE {source} SET search_path = app; \ + ALTER DATABASE {source} SET datestyle = 'SQL, DMY';" + )) + .execute(&source_pool) + .await?; + source_pool.close().await; + + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, created_by) \ + VALUES ('dtmig-ws', 'u/dtmig-admin/pg', $1, 'postgresql', 'dtmig-admin')", + ) + .bind(json!({ + "host": opts.get_host(), + "port": opts.get_port(), + "dbname": source, + "user": ROLE, + "password": ROLE_PASSWORD, + "sslmode": "disable", + })) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up) \ + VALUES ('dtmig-ws', 'main', 1, 'create_foo', 'CREATE TABLE foo (id int)')", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api", server.addr.port()); + let client = reqwest::Client::new(); + + let resp = authed(client.post(format!("{base}/w/dtmig-ws/workspaces/create_fork"))) + .json(&json!({ + "id": fork_id, + "name": "fork", + "forked_datatables": [ + {"name": "main", "new_dbname": copy, "fork_behavior": "schema_only"} + ], + })) + .send() + .await?; + // `ensure!` rather than `assert!`: a panic would skip dropping the databases below. + let fork_status = resp.status(); + anyhow::ensure!(fork_status == 200, "{}", resp.text().await?); + + let status: Value = authed(client.get(format!( + "{base}/w/{fork_id}/workspaces/datatable_migrations_status/main" + ))) + .send() + .await? + .json() + .await?; + anyhow::ensure!(status["migrations"][0]["status"] == "ran", "{status}"); + + let copied: Vec = sqlx::query_scalar( + "SELECT unnest(setconfig) FROM pg_db_role_setting \ + WHERE setrole = 0 AND setdatabase = (SELECT oid FROM pg_database WHERE datname = $1) \ + ORDER BY 1", + ) + .bind(©) + .fetch_all(&db) + .await?; + anyhow::ensure!( + copied == ["DateStyle=SQL, DMY", "search_path=app"], + "{copied:?}" + ); + anyhow::Ok(()) + } + .await; + + for name in [©, &source] { + sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")) + .execute(&db) + .await?; + } + result +} diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 0acba27c41..66e9663154 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -810,7 +810,7 @@ async fn a_data_table_under_roles_is_cloned_only_with_its_grants( // The copy an admin asks for goes ahead in an edition that replays grants, and is refused // before any database exists in one that does not. The fixture's database does not exist, so - // the dump fails either way — and leaves neither a database nor a fork behind. + // reading it fails either way — and leaves neither a database nor a fork behind. let resp = fork( "SECRET_TOKEN", json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_only"}), @@ -820,7 +820,7 @@ async fn a_data_table_under_roles_is_cloned_only_with_its_grants( assert_ne!(resp.status(), 200); let body = resp.text().await?; #[cfg(all(feature = "private", feature = "enterprise"))] - assert!(body.contains("pg_dump"), "{body}"); + assert!(body.contains("\"dt_main\" does not exist"), "{body}"); #[cfg(not(all(feature = "private", feature = "enterprise")))] assert!(body.contains("Enterprise Edition"), "{body}"); assert!(!database_exists(&db, target).await?); diff --git a/backend/windmill-api-workspaces/src/datatable_clone.rs b/backend/windmill-api-workspaces/src/datatable_clone.rs index 6083b7b619..5818419d10 100644 --- a/backend/windmill-api-workspaces/src/datatable_clone.rs +++ b/backend/windmill-api-workspaces/src/datatable_clone.rs @@ -28,10 +28,13 @@ use windmill_common::workspaces::{ use windmill_common::{PgDatabase, DB}; use crate::datatable_acl::connect_with_notices; +use crate::datatable_migrations::{ + lock_migration_runs_on, read_applied_migrations, write_applied_migrations, AppliedMigrations, +}; use crate::datatable_permissions::ensure_reaches_governing_datatable; use crate::workspaces::{ - create_database_on_server, ensure_datatable_is_clonable, pg_dump_database, pg_import_dump, - DumpFile, PgDumpOptions, + copy_database_settings, create_database_on_server, ensure_datatable_is_clonable, + pg_dump_database, pg_import_dump, DumpFile, PgDumpOptions, }; /// A database this request created and filled for one data table of the fork. @@ -336,8 +339,8 @@ async fn drop_copy(db: &DB, source_database: &DataTableDatabase, dbname: &str) - Ok(()) } DataTableCatalogResourceType::Postgresql => { - // On a server of the workspace's own, where a resource edited meanwhile can already name it - // and nothing locks such an edit: dropping it could take someone's data. + // On a server of the workspace's own, where a resource edited meanwhile can already name it + // and nothing locks such an edit: dropping it could take someone's data. Err(Error::BadRequest( "kept on its PostgreSQL server, where it may already be in use; drop it there once \ it is not, before forking the same data table under this id again" @@ -392,20 +395,25 @@ async fn make_copy( (server, Some(snapshot)) }; + let schema_only = request.behavior == DataTableForkBehavior::SchemaOnly; + let migrations_lock = if schema_only { + Some(lock_migration_runs_on(db, &server, request.name).await?) + } else { + None + }; // Dumped before the database exists, so a source that cannot be read leaves nothing behind. // Ownership never carries over: the restore runs as the target's connection user. Grants do, // except on a cluster Windmill manages, which plants its own and where the replay below is what // reproduces the roles'. let dump = pg_dump_database( &server, - PgDumpOptions { - schema_only: request.behavior == DataTableForkBehavior::SchemaOnly, - no_owner: true, - no_acl: managed, - ..Default::default() - }, + PgDumpOptions { schema_only, no_owner: true, no_acl: managed, ..Default::default() }, ) .await?; + let applied = match migrations_lock { + Some(client) => Some(read_applied_migrations(&client).await?), + None => None, + }; match source_database.resource_type { DataTableCatalogResourceType::Instance => { @@ -444,6 +452,7 @@ async fn make_copy( &server, &target, &dump, + applied.as_ref(), ) .await; match filled { @@ -484,8 +493,12 @@ async fn fill( source: &PgDatabase, target: &PgDatabase, dump: &DumpFile, + applied: Option<&AppliedMigrations>, ) -> Result { pg_import_dump(target, dump).await?; + if let Some(applied) = applied { + write_applied_migrations(db, target, applied).await?; + } // Held until the replay commits: a role renamed or dropped meanwhile would change what the // replay names, and a settings save could move the source onto another database or put it under @@ -554,6 +567,7 @@ async fn fill( })?; } tx.commit().await?; + copy_database_settings(db, source, target).await?; Ok(replayed) } diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index 21e460359e..907861c2b4 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -509,6 +509,127 @@ async fn hold_migration_lock( } } +/// The `_wm_migrations` rows of a database a schema-only copy is being made of, read with +/// [`lock_migration_runs_on`] held so they describe the schema the dump captured. +pub(crate) struct AppliedMigrations { + /// `None` when the source has no `_wm_migrations`. + schema: Option, + datatables: Vec, + versions: Vec, + installed_at: Vec, +} + +/// Hold the migration-run lock on database `pg`, for a copy whose schema has to agree with the +/// `_wm_migrations` rows it carries. The lock is per database, whichever data table names it. +pub(crate) async fn lock_migration_runs_on( + db: &DB, + pg: &PgDatabase, + label: &str, +) -> Result { + let (client, connection) = pg.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + hold_migration_lock(&client, label, MIGRATION_LOCK_WAIT_SECS).await?; + Ok(client) +} + +/// Read every `_wm_migrations` row of the database `client` is connected to, along with the +/// schema the table is in: the copy is written to by a connection whose `search_path` need not +/// resolve the way this one does. +pub(crate) async fn read_applied_migrations( + client: &tokio_postgres::Client, +) -> Result { + let read_err = |e: tokio_postgres::Error| { + Error::internal_err(format!( + "Failed to read _wm_migrations: {}", + pg_error_message(&e) + )) + }; + let schema: Option = client + .query_one( + "SELECT n.nspname::text FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + RIGHT JOIN (SELECT to_regclass('_wm_migrations')::oid AS oid) t ON c.oid = t.oid", + &[], + ) + .await + .map_err(read_err)? + .get(0); + let mut applied = + AppliedMigrations { schema, datatables: vec![], versions: vec![], installed_at: vec![] }; + let Some(schema) = applied.schema.as_deref() else { + return Ok(applied); + }; + let rows = client + .query( + &format!( + // ISO 8601 in UTC: `::text` follows the session's `DateStyle`, which the copy's + // session need not parse the same way. + "SELECT datatable, version, \ + to_char(installed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') \ + FROM {}._wm_migrations", + render_db_quoted_identifier(schema, DbType::Postgresql) + ), + &[], + ) + .await + .map_err(read_err)?; + for row in rows { + applied.datatables.push(row.get(0)); + applied.versions.push(row.get(1)); + applied.installed_at.push(row.get(2)); + } + Ok(applied) +} + +/// Write the rows [`read_applied_migrations`] read into a copy restored from a schema-only dump, +/// which brings `_wm_migrations` over empty: without them the copy reports every migration its +/// schema already holds as pending, and running them fails on objects that exist. +pub(crate) async fn write_applied_migrations( + db: &DB, + target: &PgDatabase, + applied: &AppliedMigrations, +) -> Result<()> { + let Some(schema) = applied.schema.as_deref() else { + return Ok(()); + }; + if applied.versions.is_empty() { + return Ok(()); + } + let (client, connection) = target.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + client + .execute( + &format!( + "INSERT INTO {}._wm_migrations (datatable, version, installed_at) \ + SELECT d, v, i::timestamp AT TIME ZONE 'UTC' \ + FROM unnest($1::text[], $2::bigint[], $3::text[]) AS t(d, v, i) \ + ON CONFLICT DO NOTHING", + render_db_quoted_identifier(schema, DbType::Postgresql) + ), + &[ + &applied.datatables, + &applied.versions, + &applied.installed_at, + ], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to copy _wm_migrations into '{}': {}", + target.dbname, + pg_error_message(&e) + )) + })?; + Ok(()) +} + /// 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, _)) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index bdb3b59e6e..db9c070ee9 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3594,6 +3594,97 @@ pub(crate) async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) Ok(()) } +/// Carry the source database's own settings (`ALTER DATABASE ... SET`) over to a copy. `pg_dump` +/// only emits them with `--create`, so a copy otherwise resolves unqualified names, its +/// `_wm_migrations` included, through the server's default `search_path` rather than the source's. +/// A setting the copy's connection may not set is skipped with a warning rather than failing it. +/// Called once nothing else writes to the copy, which a copied read-only default would refuse. +pub(crate) async fn copy_database_settings( + db: &DB, + source: &PgDatabase, + target: &PgDatabase, +) -> Result<()> { + let (source_client, connection) = source.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + let settings: Vec = source_client + .query( + "SELECT unnest(setconfig) FROM pg_db_role_setting WHERE setrole = 0 \ + AND setdatabase = (SELECT oid FROM pg_database WHERE datname = current_database())", + &[], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the settings of database '{}': {}", + source.dbname, + pg_error_message(&e) + )) + })? + .iter() + .map(|row| row.get(0)) + .collect(); + drop(source_client); + if settings.is_empty() { + return Ok(()); + } + + let (mut client, connection) = target.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + let quoted_db = render_db_quoted_identifier(&target.dbname, DbType::Postgresql); + for setting in settings { + let Some((name, value)) = setting.split_once('=') else { + continue; + }; + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') + { + tracing::warn!( + "Not copying database setting '{name}' to '{}'", + target.dbname + ); + continue; + } + let quoted_name = name + .split('.') + .map(|part| render_db_quoted_identifier(part, DbType::Postgresql)) + .collect::>() + .join("."); + // `FROM CURRENT` takes the value in the stored text form, which `SET name TO value` would + // need re-quoted per setting type. Set transaction-locally: a session-wide `role` or + // `default_transaction_read_only` would make every later `ALTER DATABASE` fail. + let applied = async { + let tx = client.transaction().await?; + tx.execute("SELECT set_config($1, $2, true)", &[&name, &value]) + .await?; + tx.batch_execute(&format!( + "ALTER DATABASE {quoted_db} SET {quoted_name} FROM CURRENT" + )) + .await?; + tx.commit().await + } + .await; + if let Err(e) = applied { + tracing::warn!( + "Could not copy setting '{name}' of database '{}' to '{}': {}", + source.dbname, + target.dbname, + pg_error_message(&e) + ); + } + } + Ok(()) +} + #[derive(Deserialize)] struct CreatePgDatabaseRequest { /// The datatable source to determine connection info: 'datatable://name' or '$res:path' @@ -3857,12 +3948,33 @@ async fn import_pg_database( // the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access. let no_acl = target_kind.is_some() || source_kind.is_some(); + // Only a database made to be a copy takes on the source's bookkeeping and settings: without an + // override the target is a data table's own live database, whose settings are its owner's. + let fresh_copy = req.target_dbname_override.is_some(); + let migrations_lock = if schema_only && fresh_copy { + Some( + crate::datatable_migrations::lock_migration_runs_on(&db, &source_pg, &req.source) + .await?, + ) + } else { + None + }; let dump_file = pg_dump_database( &source_pg, PgDumpOptions { schema_only, no_owner: true, no_acl, ..Default::default() }, ) .await?; + let applied = match migrations_lock { + Some(client) => Some(crate::datatable_migrations::read_applied_migrations(&client).await?), + None => None, + }; pg_import_dump(&target_pg, &dump_file).await?; + if let Some(applied) = applied { + crate::datatable_migrations::write_applied_migrations(&db, &target_pg, &applied).await?; + } + if fresh_copy { + copy_database_settings(&db, &source_pg, &target_pg).await?; + } if let Some(tx) = fork_lock { tx.commit().await?; } From 11f0e3860ea9e208e7fc80d1352df5888348eaf0 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 14 Oct 2026 10:42:29 +0200 Subject: [PATCH 05/49] oauth: github scope_options add read:project, project (#11478) Co-authored-by: Claude Opus 5.5 --- backend/oauth_connect.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index a29aecb92d..9ed7dc67bb 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -3,7 +3,7 @@ "auth_url": "https://github.com/login/oauth/authorize", "token_url": "https://github.com/login/oauth/access_token", "scopes": ["workflow", "repo"], - "scope_options": ["repo", "workflow", "gist", "read:org"] + "scope_options": ["repo", "workflow", "gist", "read:org", "read:project", "project"] }, "gitlab": { "auth_url": "https://gitlab.com/oauth/authorize", From 998e6877c1cf07bb1a08fd1d3b18948fbc0e30e0 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 14 Oct 2026 10:58:20 +0200 Subject: [PATCH 06/49] feat(sessions): run a deployed item from its own page, for the chat (#11468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sessions): hand a deployed run to the item's own page A deployed `run_script` / `run_flow` opened a form in the chat and started the job itself. It now opens the item's deployed page in the preview panel, fills the run form that is already there with the arguments it proposed, and the call ends. The run is the reader's: they start it from the page they would have run it from anyway. **Why the tool stops owning the run.** What the tool could carry was arguments and nothing else, so the page had to hide the controls it could not forward — scheduling, tag override, invisible-to-owner — and "Schedule to run later" ran immediately. It also ran HEAD whatever version the page was pinned to. Handing the page over costs the model nothing it had: it never ran anything, it waited for a reader to press Run. Now it says so and moves on; `get_run` is there when it needs the outcome. A TEST run is unchanged. It executes the draft being written, which the deployed page does not show, so it keeps the chat's own form. So does the bypass posture, which answers for a reader who has already consented and would leave nobody to press Run. Plan mode is unaffected: its gate already sits before the form, so it refuses before anything opens. **On the page.** The arguments arrive narrowed against the deployed schema by the tool that fetched it, and a note above the form says where they came from; dismissing it drops the note and keeps the values. A chat flow has no run form — its page is a conversation — so the proposed `user_message` goes into the composer instead, for the reader to edit and send. The composer declines if they were already typing. **On the card.** The call settles as `Opened`, with the arguments it filled under Inputs and no outcome pane: nothing ran, and a Result tab could only say the run came back empty. The eye reopens the page. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): address review round 1 on the deployed-run handover - the chat-flow seed was latched before the composer existed, so a page opened fresh dropped the proposed message; the host's input is now reactive and the latch waits for an offer the composer actually took - that offer carries the run's other arguments too: a chat flow's schema can declare more than user_message, and the message alone would have run on saved values or schema defaults - the page takes its seeded arguments out of the runtime rather than reading them on every mount, so a later open of the same item is not re-seeded with an old turn's proposal - the card said "Done" beside "Opened", asserting a completion nothing claims - run_script / run_flow say what the call does in a session, and background / wait_seconds say they do not apply there - restored runFormCancelled's doc comment, displaced by the new helper Tests pin the three invariants the handover rests on: no job started, the model told the run has NOT happened, and neither a test run nor the bypass posture taking the branch. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): tell a mounting composer apart from a busy one The retry that waits for a chat flow's composer could not tell "not there yet" from "the reader is writing": both read as a declined offer, so the proposal stayed pending and would have landed in the box whenever the composer next changed — words appearing long after they were proposed. `composerReady` answers the first question on its own; past it the proposal is settled either way, and a reader's draft is left alone. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): address review round 2 on the deployed-run handover - a seed consumed into the viewer's own state carried no item key, so a tab re-pointed to another item got the first one's arguments pushed into its form, replacing its defaults, under a note crediting the agent for them - the handover ran after a stopped turn: the tool reads the item's schema first, and a stop during that read settles the card, which the handover then overwrote with a form so the card read `Opened` and hid the stop. The manager owns the turn, so the check lives there and declining hands the call to requestRunArgs, whose own stop guard settles it - the card kept the deployed schema in the persisted transcript although it never mounts a form - a chat flow's non-message inputs now land whether or not the message does, and the result string no longer asserts a fill the chat branch may not have made - the callback's `false` is documented by what actually causes it Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): mint a chat flow's secret arguments before its run A chat flow's inputs reach the job straight from the composer, with no RunForm between them to turn a password-marked value into a reference as it is typed. The handover put the proposed arguments there directly, so a literal secret was stored in the job's arguments, readable by anyone who can read the run — the form path had always minted it first. runFlowForChat now does, which covers the reader's own typed inputs as well as a proposal's. Also from round 2: - a viewer pinned to an older version kept that pin when the handover merely focused it, so Run executed a version the arguments were not prepared against - the proposal now decides every input the schema declares, including the ones it omits: merging let a value saved on an earlier visit ride along in a run the chat announced without it Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): apply a run proposal once, not on every remount The detail page remounts under its viewer whenever the item is deployed, refreshed or pinned to a version, and came back with its own latch reset — so the agent's proposal was pushed in again, over whatever the reader had typed since, bringing back a note they had dismissed. The proposal was made once. The viewer now holds it as pending until the page reports applying it, and says separately whether to credit the agent: the note stands until dismissed, the values land once. Also from the CI round's nits: - a pinned viewer the handover re-points is brought to the front, since the call is about to say the page is open - offerMessage's doc described inputs it no longer carries; applyInputs owns them, deliberately unconditional on the message landing Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): show the panel on a pinned handover, keep the eye alive after a stop Both regressions from the previous round's fixes. Re-pointing a pinned viewer did neither of the things `open` does besides re-targeting: a collapsed panel stayed collapsed while the call told the reader the page was open. The stop check sat on the method the card's eye calls too. The controller is per-turn and stays aborted once stopped, so the eye went dead on every card in a chat where any turn had been stopped. The tools now go through a guarded variant; the reader's own way in carries no turn check, because reopening a page they closed has nothing to do with a turn. Co-Authored-By: Claude Opus 5 (1M context) * docs(sessions): put the run-form doc comments back on their functions Inserting the two handover methods stranded handleRunFormSubmit's comment above one of them, and the host hook's comment still named a tool path that no longer reaches it. Co-Authored-By: Claude Opus 5 (1M context) * feat(sessions): run a deployed item from its own page, for the chat The run card stays in the chat by default. Preview opens the item's deployed page in the preview panel with the proposed arguments already filled, and Run there hands them back to the parked tool call — so the chat still owns the job and reports on its result. A chat flow has no run form to fill: its page is a conversation, so the proposed message goes into the composer and a strip above it says what was asked for. Sending it runs the flow in that conversation, which settles the call with a reason rather than a refusal — the card says "Handed off", not "Cancelled". Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): hand the call back when the page cannot honour it A page carries an adopted call only while it can run what the call asked for. Picking a version broke that silently: the tool starts its job by path, so Run on a page pinned to an older version ran the deployed one instead — the page said v1 and the job returned v3. The page now hands the call back when it is on a version, and the card takes its form back, which is where the call can still be confirmed as proposed. Whether the call is still waiting is the manager's answer, not the adoption map's. Reading it live covers the ends that reach no hook: a submit whose job never started, because the server refused it or plan mode blocked it after the arguments were handed over. Those left the page with a dead Run and no way out but the X. The page's Run also has to clear what the card's clears before the form mints a password into a workspace variable — plan mode, and a second press. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): bind a press on the page to the call it started against A call stops being pending the instant Run is pressed, well before its job exists. The page went back to running on its own in that gap, so a second press there started a job the chat never heard about. A press now holds the call until the job lands or the call settles. A press also kept reading its runner across `processSecretArgs`: cancelling while a password was being minted switched the press to the page's own runner, which then started a job the reader had just cancelled. The runner is read once, when the press begins. A chat flow's message waits on that same round trip. It needs nothing from it, but a composer filled ahead of the inputs can be sent ahead of them, and the run would carry the inputs the proposal was meant to replace. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): tell the form when a submitted call will never get a job A surface that confirmed a run waits for the job it was promised, and a refused start is the one end it cannot see: the call is neither pending nor cancelled. It was inferred from the call no longer being pending, which stopped working as soon as a press had to outlive that — the page then waited forever, offering a Run that reached nothing. `markRunFormEnded` is the mirror of `markRunFormStarted`: the server refused the job, a sensitive argument could not be stored, or plan mode blocked it after the arguments were handed over. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): bind the page's async work to the call it started with The page reads the call it is carrying from a derived, and every await on it outlives that read: a call can settle and the next request for the same item be adopted inside one `processSecretArgs` round trip. Read live on the far side, the page would submit one request's arguments to another, decline a proposal the reader had not been shown, or seed a composer over the one they were looking at. Each of these now holds the call it began with. The form's own press holds its guard too, so a replacement cannot have its latch released by the press before it. Co-Authored-By: Claude Opus 5 (1M context) * refactor(sessions): let the tab carry the call, and leave chat flows alone The page that answers a parked run form was found by item, so anything on that page could resolve the call and act on it: a version picked after the fact, a press that outlived its call, a send in some other conversation. Each was fixed where it surfaced, and the next one surfaced somewhere else. The tab carries the call now, named in its url beside the version pin. Only the tab opened for a call can answer it, and re-pointing that tab anywhere drops the name, so a version picked on the page hands the call back with nothing to check for it. The item-keyed map and the lifecycle hooks around it are gone. A chat-enabled flow's page is a conversation with no run form, so it cannot answer a call at all: Preview opens it to look at and the card keeps the run, which is what the agent needs to report a result. That deletes the composer seeding, the strip above it, and the "Handed off" outcome that existed only to describe a run the agent never saw. Prefilling that composer is worth having on its own and comes back in its own PR. Running a deployed chat-enabled flow needs a memory id, which this path never sent: the card's Run answered with a 400. Each such run now starts its own conversation on the flow's page. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): keep the page's promises and its edits when it holds a call The dynamic-select helper runs its job when the field mounts, not when Run is pressed, so a form relocated to the page ran one under plan mode — which the card withholds for exactly that reason. The page owes the same promise while it is standing in for a card. Its form is a copy, so arguments typed there were dropped when the call went back to the card: it then offered, and ran, what the page had opened with. The call now carries them back. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): take no writes on a relocated form under plan mode A password field mints an ephemeral workspace variable as it is typed, so withholding the dynamic-select helper was half the promise: the field itself has to be unwritable. One flag now covers both, as the card's own form already did. The hand-back is keyed on the call rather than on the object naming it, which is rebuilt whenever the draft or the posture changes — its own write to that draft is what would have re-run it. It also hands back only what its own form held: the next call for this item re-points the same tab without remounting, and its seeding can land first. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): close the JSON route into a read-only run form Disabling the fields left the other way in: arguments written as JSON reach the same object, and a password set there is a plain literal until the schema field mounts onto it — which mints it whether or not that field is disabled. The editor's writes are refused while the form is read-only, and the toggle offering it is not drawn. Co-Authored-By: Claude Opus 5 (1M context) * refactor(sessions): hand the form back on destroy rather than from an effect Every way the page stops carrying a call and still holds edits destroys the component: the tab closes, or its url changes and `loadKey` remounts it. So the hand-back is a destroy hook, not a reaction to the call changing — which needed a key and two `untrack`s to keep its own write from re-running it. The call is the one this form was seeded for, captured where it is seeded. By the time the hook runs the tab has already stopped naming it, so reading it then would find nothing to hand back to. Co-Authored-By: Claude Opus 5 (1M context) * refactor(sessions): seed the run form as it is built, not after it mounts The form takes what it opens holding as a prop, and the page renders it under the call's id. A request arriving on a tab that is already open builds a new form already filled, so there is no mounted instance to reach for and nothing to wait on — which is all the seeding effect was doing. That also settles where the hand-back reads its call: with no instance to track, `onDestroy` reads the prop, which is still there. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): hand back only what a form actually held A tab closed while the deployed item was still being fetched handed the card an empty object, wiping the proposal it was still parked on: the page registers its destroy hook long before `RunForm` gives `args` a value. The comment above it also claimed every way out destroys the page. The next call re-pointing the same tab does not, and that page's edits stop there. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): hand back the PowerShell common parameters too `RunForm` keeps `_wm_ps_*` out of `args` and puts them back only as it submits, so handing `args` to the card dropped whatever the reader had chosen on the page — ErrorAction among them, which falls back to Stop. The form now answers with what a run would send. Co-Authored-By: Claude Opus 5 (1M context) * refactor(sessions): let the page edit the call's own draft The page kept a copy of the arguments and handed it back when it went away, so everything `RunForm` does to arguments on the way through had to be mirrored on the way out: the PowerShell split was the third thing found missing from that mirror. The form now binds the draft the card binds, so there is one set of arguments and nothing to carry anywhere. That removes the hand-back, the destroy hook holding it, the `initialArgs` the form was seeded through and the guard that stopped an unseeded form wiping the proposal. A call arriving on an open tab still rebuilds the form, which is what `{#key}` is for; it now opens on the draft rather than on a prop. The PowerShell section follows the rule the rest of the run settings already follow: a call carries arguments and nothing else, so the section is not offered while the form stands in for a card — nothing can be chosen there and lost. Its keys stay in the arguments when the section is not offered, so a proposal carrying them reaches the job either way. Co-Authored-By: Claude Opus 5 (1M context) * fix(sessions): keep the submitted arguments on screen after Run Pressing Run settles the call, so the draft the form reads stops answering before the job exists: the form emptied for as long as the start took, and stayed empty when the server refused it — leaving the reader the run to make again with nothing to make it from. It falls back to what it submitted. The note on the PowerShell split also claimed the unoffered keys travel to the job. `SchemaForm` holds the arguments to the keys the schema declares, so they do not. Co-Authored-By: Claude Opus 5 (1M context) * docs(sessions): trim the comments on the adopted-run path Co-Authored-By: Claude Opus 5 (1M context) * refactor(sessions): let the page run it and hand the call its job The page kept the chat's arguments in step and ran on the chat's behalf, which meant reproducing everything `RunForm` does to arguments on the way through — the PowerShell split was the third thing found missing from that. It now runs the way it runs anything, with its own form, its own version and its own arguments, and tells the call which job that was. The tool follows that job instead of starting one. The proposal reaches the form through the url hash the deployed pages already read for "Run again", so nothing seeds it. `PendingRun` is three members, and `RunForm` goes back untouched but for the plan-mode gate. Two classes go with the copy. A version pinned on the page is simply the version that runs, since the page starts the job. PowerShell common parameters travel with it for the same reason. Plan mode still has to reach the page: a `password` mints as it is typed and the dynamic-select helper runs on mount, both before Run is pressed, and the page's own Run would otherwise start a job the chat promised not to make. Co-Authored-By: Claude Opus 5 (1M context) * fix: carry a run proposal on the call, not in the tab url The proposal reached the deployed page through the tab url's hash, which the page already reads for "Run again". Two things were wrong with that carrier: - A tab already open on the item does not remount when it is re-pointed, and both detail pages read the hash once at init — so a second proposal for the same item opened its form on the first one's arguments. - Tab urls are persisted with the session, so a draft holding file bytes or an un-minted `password` literal was written to IndexedDB. The transcript avoids both deliberately, through `redactFileArgs` and `MAX_PERSISTED_ARGS_LENGTH`. The call now carries its own proposal: `PendingRun.proposedArgs` reads the card's live draft, the page seeds its form from it at init, and the tab's url names only the call. `ItemViewerView`'s load key includes that name, so a tab re-pointed to another call builds its form again. Two more fixes on the same path: - `adopt` was read off the prop after awaiting job creation, so a call that settled during the request could be answered with another call's job. The press now captures the call it was made under. - An adopted job's arguments and version are the page's, which this tool cannot read, but the model was told `Ran with arguments: {}`. It is now told the user ran it on the item's own page, and that the arguments are the page's. - Adopting hands the carrying tab to the run, so the page no longer navigates there as well — that left two tabs open on the same job. Co-Authored-By: Claude Opus 5 (1M context) * fix: refuse a run in the form, and show an adopted job's own arguments Two findings on the deployed page carrying a chat call. `RunForm.run()` mints a variable per `password` argument before it calls `runAction`, so the plan-mode gate in the detail pages ran after the write it was there to prevent — and the keybinding reached it without the button. The refusal moves into the form, as `runRefusal`: set, it takes no writes at all, its submit buttons are disabled, and `run()` says so and returns before the secret processing. The page also stops offering the JSON editor under plan mode, instead of leaving one mounted that silently discards what is typed. An adopted job's arguments are the page's, and the card had no way to show them: it kept rendering the proposal, so a reader who edited a field saw the wrong values and the model was told nothing. `adopt` now hands them over with the job id, and they go through the same redaction and size guard as a run this tool starts. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the refused form on the side it is showing Closing the JSON editor when the form starts refusing writes mounted `SchemaForm` in its place, and `PasswordArgInput` mints a variable from the literal it opens on without consulting `disabled` — so a reader who typed a plaintext secret into JSON view had it stored by the act of entering plan mode, with no run and no Run press. The form no longer swaps sides while it refuses: `JsonInputs` takes `readOnly` and the editor stays mounted and uneditable. The JSON toggle is already hidden in that state, which is what keeps the fields from mounting from the other direction. Co-Authored-By: Claude Opus 5 (1M context) * fix: redact an adopted run against the schema its page ran Redaction only visits the leaves the schema it is given declares, and the page runs the deployed version as it stands when Run is pressed. A deploy between this call reading the schema and that press can add a file argument ours never had, and its bytes then passed unredacted into the persisted card and the model's result, under the size limit that would have caught a large one. `adopt` hands over the schema it ran against alongside the job and arguments, and both redactions use it. Co-Authored-By: Claude Opus 5 (1M context) * fix: sit the parked-call banner in the form's own header row The banner was its own block directly above the form, so it sat flush against the first field. It now shares the row that carries the JSON toggle, where the existing input badges get their spacing from `min-h-12`. It goes ahead of the saved-input badge, whose reserved slot stays mounted while hidden and would otherwise push the banner off the left edge. The row's gate takes `pendingRun` as well, since a runnable with no schema properties renders no row and would drop the banner with it. The badge's reject button also takes the alert palette it sits on, through `tone`, instead of the neutral bordered one it had from the deprecated `color`/`size` props. Co-Authored-By: Claude Opus 5 (1M context) * feat: hand a proposed run to the page already showing it A reader who declines a proposal and asks for different arguments still has that item's page open, and the next proposal belongs on it — but it was parked on the card until Preview was pressed again. `requestRunArgs` now routes the call to that page itself, through `openRunOnDeployedPage` with `onlyIfAlreadyOpen`: a tab already on the item is re-pointed, and anything else — a tab showing a run included — answers no and leaves the form on the card. The card keeps a working form while a page carries the call, rather than reporting that its inputs are elsewhere. Only the chat's own form in a tab is exclusive with it, since that one is the same draft and two mounts each reorder the schema in place; a page builds its form from a copy of the proposal and shares nothing. So the card folds its row instead of emptying it, and expanding it is how a run goes from the transcript. `isRunFormTabOpen` answers that narrower question, and the collapse moves into the row's default so pressing Preview and being routed here behave alike. Co-Authored-By: Claude Opus 5 (1M context) * fix: let one call start only one job across its two surfaces The card's form and the deployed page's are now live at the same time, and only the card took the manager's submission claim. The call stays pending until `adopt`, a round trip after the page sends its request, so a Run on the card in that window passed the claim and started a second job — with its own arguments, and unmentioned in the chat, which followed the card's. The page takes the same claim before it sends, and hands it back when no job came of it so a failed start can be retried. A successful start needs no release: adopting the job settles the call. Whichever surface is first owns the call; the other sends nothing and says so. Co-Authored-By: Claude Opus 5 (1M context) * fix: leave a busy tab alone when handing it a proposal The re-point branch was written for Preview, which the reader presses on purpose, and now also runs for every proposal of an item whose page is open. Unasked for, two of the things it does are not wanted: `navigate` rebuilds the tab's url without the version the reader pinned, and `select` with `setCollapsed` pulls the panel off whatever tab they were reading. So the automatic path declines a pinned tab outright, leaving the pin and the call on the card, and moves the panel only when that tab is the one already on screen. An explicit Preview press keeps the behaviour it had. A hand-off still remounts the page, so text typed into its form is lost. That needs the page to report unsent edits upward, and is left out. Tests the routing gate: a deployed run is handed over, a test run and a chat-enabled flow are not — neither has a page with a form that could answer the call. Co-Authored-By: Claude Opus 5 (1M context) * fix: claim a run before the form mints its secrets, not after The claim sat in `runAction`, which `RunForm.run()` reaches only after `processSecretArgs` has written a variable per `password` argument. So a press that lost the race still minted — and worse, it read the pending call after that round trip, by which point the winner had settled it: with no call to claim, the losing press started a job of its own. `runRefusal` already documents this boundary, saying a caller cannot gate in `runAction` because it comes after the mint. The claim belongs on the same side, so the form takes it through `claimRun` just before the first write and hands it back through `releaseRun` when the mint fails. The pages keep the call they claimed rather than re-reading it, so a claimed press either answers that call or does nothing. The chat card's own form already worked this way, taking the claim ahead of its `processSecretArgs`; the page was the asymmetric surface. Co-Authored-By: Claude Opus 5 (1M context) * fix: hand a proposal only to the page the reader is looking at Re-pointing a tab reloads it and rebuilds its url. On a tab that is not on screen that silently drops whatever had been typed into its form and leaves the call on a page the reader cannot see, while the card folds because the call is carried — so the run is confirmable on neither surface in view. Declining instead leaves it on the card, which can run it. The automatic path therefore requires the matching tab to be the active one in an open panel, alongside the existing version-pin check, and the three ways of being busy read as one condition. An explicit Preview press still re-points, reveals and unpins, which is what pressing it asks for. Co-Authored-By: Claude Opus 5 (1M context) * Revert "fix: claim a run before the form mints its secrets, not after" This reverts 6c2ad4bbf6 and the claim it built on in 5ea8769efe, leaving the hand-off rule from 93ac4f495e in place. Both surfaces of a parked call show a Run button, and pressing both starts two jobs. That is two deliberate confirmations of one proposal, and pressing Run twice on a deployed page has always started two jobs — nothing in Windmill deduplicates a run, because a run is not an idempotent operation. The window is one job-creation round trip. Covering it cost a prop on `RunForm`, which pages outside this feature use, to coordinate something that only arises in a session's preview panel. It also created a problem of its own: a losing press minted an ephemeral variable per `password` argument for a run it then refused, leaving the workspace with secrets belonging to no job. Without the claim there is no losing press, so every mint belongs to a job that ran. What remains true and intended: the second job is a normal run, listed and reported like any other, and the chat follows only the one that answered its call. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/components/JsonInputs.svelte | 8 +- frontend/src/lib/components/RunForm.svelte | 41 +++- .../copilot/chat/AIChatManager.svelte.ts | 92 +++++++- .../copilot/chat/AIChatManager.test.ts | 48 ++++ .../copilot/chat/RunScriptCard.svelte | 67 ++++-- .../copilot/chat/global/core.test.ts | 209 +++++++++++++++--- .../components/copilot/chat/global/core.ts | 70 +++++- .../src/lib/components/copilot/chat/shared.ts | 21 +- .../lib/components/details/FlowDetail.svelte | 65 +++++- .../components/details/ScriptDetail.svelte | 52 ++++- .../src/lib/components/details/pendingRun.ts | 27 +++ .../schema/InputSelectedBadge.svelte | 30 ++- .../components/sessions/ItemViewerView.svelte | 23 +- .../components/sessions/PreviewTabHost.svelte | 6 + .../lib/components/sessions/previewRouter.ts | 23 +- .../sessions/sessionPreviewTabs.svelte.ts | 23 +- .../sessions/sessionRuntime.svelte.ts | 113 +++++++++- 17 files changed, 795 insertions(+), 123 deletions(-) create mode 100644 frontend/src/lib/components/details/pendingRun.ts diff --git a/frontend/src/lib/components/JsonInputs.svelte b/frontend/src/lib/components/JsonInputs.svelte index 2f4b04f8e4..546ab36069 100644 --- a/frontend/src/lib/components/JsonInputs.svelte +++ b/frontend/src/lib/components/JsonInputs.svelte @@ -13,13 +13,18 @@ * payload nobody has typed into tracks the schema instead of going stale. The first edit * hands the buffer to the user and later changes stop overwriting it. */ initialCode?: string + /** Show the payload without taking edits. For a caller that must keep this editor + * mounted while refusing writes: swapping it for the fields instead would mount the + * widgets, and a `password` field mints a variable from whatever literal it opens on. */ + readOnly?: boolean } let { updateOnBlur = true, placeholder = 'Write a JSON payload. The input schema will be inferred.

Example:

{
  "foo": "12"
}', selected = false, - initialCode = '' + initialCode = '', + readOnly = false }: Props = $props() let pendingJson = $state(untrack(() => initialCode)) @@ -130,6 +135,7 @@ bind:code={pendingJson} lang="json" class="h-full json-inputs-editor" + {readOnly} {placeholder} />
diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 7be09785b1..c01f7598ca 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -75,6 +75,13 @@ } export async function run(overrideScheduledForStr?: string | undefined | null) { + // Ahead of everything, including the flush below: the secret processing further down + // creates a variable per `password` argument, so a refusal that waits for `runAction` + // has already written. The keybinding reaches here without the button. + if (runRefusal) { + sendUserToast(runRefusal, true) + return + } // An editor whose text does not parse never wrote it to `args`, so running now would send // the last value that did parse. Flush first: a keystroke still inside the editor debounce // has not been parsed yet, and per-field editors parse it in an effect, hence the tick. @@ -127,6 +134,15 @@ extra_perms?: Record } | undefined + /** Set to refuse every write this form can make, and to say so when a run is + * attempted: the fields are disabled, the dynamic-select helper is not wired, the JSON + * editor is read-only, and `run()` refuses before it mints the `password` arguments it + * would send — so a caller cannot gate this in `runAction`, which comes after that + * mint. For a form standing in for a chat card under plan mode, which promised none of + * it. The caller must also leave this form on the side it is showing: swapping fields + * for JSON or back mounts a `password` widget, which mints from the literal it opens + * on regardless of being disabled. */ + runRefusal?: string runAction: ( scheduledForStr: string | undefined, args: Record, @@ -160,6 +176,7 @@ let { runnable, runAction, + runRefusal = undefined, buttonText = 'Run', schedulable = true, detailed = true, @@ -178,6 +195,8 @@ actions = undefined }: Props = $props() + const argsReadonly = $derived(!!runRefusal) + let showPsCommonParams = $derived( runnable?.language === 'powershell' && runnable?.schema?.['x-windmill-ps-cmd-binding'] === true ) @@ -333,7 +352,10 @@ > { + // The other way into the arguments, and the one disabling the fields misses. + if (argsReadonly) return blockedByUnparseable = false if (e.detail) { args = enforceDisabledDefaults(e.detail, runnable?.schema).args @@ -350,11 +372,14 @@ {#key reloadArgs}
run()} shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }} > @@ -431,7 +456,7 @@ variant="accent" unifiedSize="md" btnClasses="!inline-flex" - disabled={!isValid && !jsonView} + disabled={(!isValid && !jsonView) || argsReadonly} on:click={() => run(null)} shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }} > @@ -444,7 +469,7 @@
diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index 5c8299de15..e9fa9b7768 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -28,6 +28,7 @@ kind, path, version, + pendingRunId, locationHash = '', dbtRetryFrom, workspaceId, @@ -44,6 +45,8 @@ path: string /** Deployed version this tab is pinned to, from its URL's `?version=`. */ version?: string + /** The chat tool call this tab was opened to answer, from its URL's `?pending_run=`. */ + pendingRunId?: string /** `#`-fragment of the tab's URL: the args a run's `Run again` prefills the form with. */ locationHash?: string /** The failed dbt run that `Run again` offers to resume, from `?dbt_retry_from=`. */ @@ -82,6 +85,16 @@ reloadKey++ }) + // The call waiting on this item's run form, when the reader chose to confirm it here + // rather than on the card. Read live from the runtime, so it disappears the moment the + // call settles — by Run, by Cancel, by a stopped turn or by this tab closing. + // + // Named by this tab's own url rather than looked up by item, so only the tab opened for + // the call carries it. A tab re-pointed anywhere — another version of this item included + // — loses the name and hands the call back to the card, which is where it can still be + // confirmed as proposed. + const pendingRun = $derived(pendingRunId ? runtime.pendingRunFor(pendingRunId) : undefined) + /** Refetch the deployed item. The host calls this for the reload signals that reach a * tab from outside it; nothing about becoming visible triggers it, so a form the reader * filled in survives switching tabs away and back. */ @@ -98,10 +111,12 @@ : undefined ) - // Remounts the detail page below, which loads on mount. - // The fragment is part of it: the detail pages seed their form from it once, on mount. + // Remounts the detail page below, which loads on mount. The fragment and the call are + // part of it because the detail pages read both once, as they build their form: from the + // fragment for a `Run again`, from the call for what the chat proposed. So a tab already + // on this item, re-pointed to either, has to build that form again. const loadKey = $derived( - `${workspaceId}/${kind}/${path}/${version ?? ''}/${locationHash}/${reloadKey}` + `${workspaceId}/${kind}/${path}/${version ?? ''}/${locationHash}/${pendingRunId ?? ''}/${reloadKey}` ) // Nothing deployed at this path. Stamped with the load it answers, so a refetch or a // re-pointed tab is never answered from an earlier load's verdict. @@ -234,6 +249,7 @@ {onNavigate} {active} embedded + {pendingRun} onLoadState={setLoadState} /> {:else if kind === 'flow'} @@ -245,6 +261,7 @@ {onNavigate} {active} embedded + {pendingRun} onLoadState={setLoadState} /> {:else} diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 1ecb921c98..c2adaf7688 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -100,6 +100,11 @@ // What a run's `Run again` hands the deployed page it opens: the run's args in the // fragment, and for a dbt run the failure it could resume instead. const viewerUrl = $derived(slot.kind === 'viewer' ? new URL(tab.url, 'http://x') : undefined) + // The chat tool call this tab was opened to answer rides in the same query. Re-pointing + // the tab rebuilds its url without it, so a tab sent anywhere else — another item, + // another version — stops carrying the call rather than answering it from a page it was + // never confirmed against. + const pendingRunId = $derived(viewerUrl?.searchParams.get('pending_run') ?? undefined) // The path an editor reports is its deployed one, which a staged rename can make // differ from this tab's — so the view opens on what actually exists. @@ -401,6 +406,7 @@ kind={itemKind} path={itemPath} version={pinnedVersion} + {pendingRunId} locationHash={viewerUrl?.hash} dbtRetryFrom={viewerUrl?.searchParams.get('dbt_retry_from') ?? undefined} {workspaceId} diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 4d9a678859..501fc1c742 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -86,7 +86,19 @@ export type PreviewTarget = // caller that predates the View side keeps opening the editor. `version` pins the // View side to one deployed version, in the query so the tab's identity stays the // item's path rather than becoming the version's own hash. - | { type: 'item'; item: WorkspaceItem; mode?: PreviewItemMode; version?: string } + // `pendingRunId` hands this tab a chat tool call parked on a run form: the deployed page + // shows that form, and Run there answers the call instead of starting a job of its own. + // In the query for the same reason as `version` — the tab stays the item's, and the call + // is a property of this tab rather than of the item. The id alone: what the call proposed + // is read off the chat, since a tab's url is persisted with the session and those + // arguments can hold file bytes and secrets typed into the form. + | { + type: 'item' + item: WorkspaceItem + mode?: PreviewItemMode + version?: string + pendingRunId?: string + } | { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget } | { type: 'runform'; toolCallId: string; label: string } | { type: 'pageitem'; ref: PageItemRef } @@ -556,6 +568,15 @@ export function parseRunFormRoute(url: string): { toolCallId: string; label: str } } +/** The chat tool call a viewer tab was opened to answer, read off the tab's url. A deployed + * page tab is still the item's tab — only the call rides in its query — so this is how a tab + * says which call it is carrying, and re-pointing it anywhere drops the answer. */ +export function pendingRunIdOf(url: string): string | undefined { + const slot = resolvePreviewTab(url) + if (slot.kind !== 'viewer') return undefined + return new URL(url, 'http://x').searchParams.get('pending_run') ?? undefined +} + /** The run a `/run/` location shows, in the workspace its query names (the opener's, * when it names none). */ export function parseRunRoute( diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index b1f14fadbe..ee13729d4d 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -113,10 +113,16 @@ function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string { // an item the user left on View does not yank them back to the editor. const mode = target.mode ?? (onto ? (parsePreviewItemRoute(onto.url)?.mode ?? 'edit') : 'edit') if (mode !== 'view') return editPathFor(target.item) - // Not carried over from `onto`: a version pin is something the reader asked for - // once, so every other re-point of this tab lands on the current deployed version. - const pin = target.version ? `?version=${encodeURIComponent(target.version)}` : '' - return `${viewPathFor(target.item)}${pin}` + // Neither is carried over from `onto`. A version pin is something the reader asked for + // once, so every other re-point of this tab lands on the current deployed version — and a + // chat tool call is carried by the tab that was opened for it, so re-pointing that tab + // anywhere else, a version included, hands the call back rather than running it against + // something it was not confirmed for. + const params = new URLSearchParams() + if (target.version) params.set('version', target.version) + if (target.pendingRunId) params.set('pending_run', target.pendingRunId) + const query = params.size > 0 ? `?${params}` : '' + return `${viewPathFor(target.item)}${query}` } // Point a tab at a new destination. Clears `friendlyLabel`/`friendlyPath` @@ -686,6 +692,15 @@ export class SessionPreviewTabs { this.#flush() } + /** Point one known tab at `url`. For a caller that found the tab itself — the deployed + * page that confirmed a run becomes that run, and only the caller knows which page. */ + retargetById(id: string, url: string): void { + const tab = this.#tabs.find((t) => t.id === id) + if (!tab) return + retargetTab(tab, url) + this.#flush() + } + setCollapsed(collapsed: boolean): void { if (this.#collapsed === collapsed) return this.#collapsed = collapsed diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index b84bd74cbe..6d4b7f568d 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -1,4 +1,5 @@ import { SvelteMap } from 'svelte/reactivity' +import type { PendingRun } from '$lib/components/details/pendingRun' import { get } from 'svelte/store' import { invalidate as invalidateWorkspaceItems } from '$lib/components/workspacePicker' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' @@ -77,6 +78,7 @@ import { promptSafe, parseRunFormRoute, resolvePreviewTab, + pendingRunIdOf, slotShowsPage, type PreviewSlot } from './previewRouter' @@ -248,6 +250,8 @@ export interface SessionRuntime { * viewer refetches when this changes — it reads the deployed version from the * API, so nothing else tells it the editor beside it just published. */ deployedRevision(kind: SessionTargetKind, path: string): number + /** The chat tool call a viewer tab was opened to answer, named by that tab's own url. */ + pendingRunFor(toolCallId: string): PendingRun | undefined /** Record that a preview tab landed on `url`. Fires for a newly opened tab and * for an existing one switched to the other side of its item. */ logTabUsage(url: string): void @@ -588,10 +592,30 @@ function createRuntime(session: Session): SessionRuntime { // Only persist a real width; undefined means "never resized" (defaults to 50). if (snap.previewSize != null) setSessionPreviewSize(session.id, snap.previewSize) }, + // Closing a tab that carried a call is not a refusal: the card still has the form. onTabsChanged: pruneEditorCells, onTabOpened: logTabUsage }) + /** The call a viewer tab was opened to answer, for the page that renders it. Built from + * the id the tab carries rather than from state of our own, so there is nothing to keep in + * step: the tab names the call, and the manager says whether it is still waiting. */ + function pendingRunFor(toolCallId: string): PendingRun | undefined { + if (!manager.isRunFormPending(toolCallId)) return undefined + return { + toolCallId, + // Read here, so the page's `$derived` rebuilds when the posture changes under it. + planModeActive: manager.planModeActive, + // A getter, not a value: the page reads it once, when it builds the form, and this + // object is rebuilt on every keystroke of the card's draft otherwise. + get proposedArgs() { + return manager.pendingRunFormArgs(toolCallId) ?? {} + }, + adopt: (jobId, args, schema) => manager.handleRunFormJob(toolCallId, jobId, args, schema), + decline: () => manager.handleRunFormCancel(toolCallId) + } + } + // Let the jobs tray open a run in this session's preview panel (as a tab on // the run's page). The global side-panel chat leaves this unset and // falls back to a new browser tab. @@ -609,15 +633,89 @@ function createRuntime(session: Session): SessionRuntime { manager.openRunForm = ({ toolCallId, label }) => { previewTabs.open({ type: 'runform', toolCallId, label }) } - manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId) - manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => { - previewTabs.retargetRunForm(toolCallId, `/run/${jobId}?workspace=${workspace}`) + // Show a deployed run on the item's own page. With `carries`, that page answers the call: + // the job its Run starts is the one the tool follows. + manager.openRunOnDeployedPage = ({ + toolCallId, + kind, + path, + summary, + carries, + onlyIfAlreadyOpen + }) => { + // Through the shared adapter: a session target spells a code-based app `raw_app` and a + // workspace item spells it `app` with a flag, and one place should know that. + const target = previewTargetForSessionTarget(kind, path) + if (target?.type !== 'item') return false + const viewTarget = { + ...target, + item: { ...target.item, summary }, + mode: 'view' as const, + pendingRunId: carries ? toolCallId : undefined + } + // Re-pointed rather than focused: the url is what hands the tab the call, and + // rebuilding it also drops whatever version the reader had pinned. + const open = previewTabs.tabs.find((t) => { + const slot = resolvePreviewTab(t.url) + return slot.kind === 'viewer' && slot.viewerKind === kind && slot.path === path + }) + if (open) { + // An unasked-for hand-off goes only to the page the reader already has in front of + // them. `navigate` below re-points the tab, which reloads it and rebuilds its url: + // on a tab they cannot see that silently drops whatever they had typed into its + // form and leaves the call on an off-screen page, and on a pinned one it moves them + // off the version they chose. Declining leaves the call on the card, which can run + // it. A reader who presses Preview is asking for all of that. + if ( + onlyIfAlreadyOpen && + (previewTabs.activeId !== open.id || + previewTabs.collapsed || + new URL(open.url, 'http://x').searchParams.get('version')) + ) { + return false + } + previewTabs.navigate(viewTarget, open.id) + // `navigate` re-points a tab in place and nothing more, while the call is about to + // tell the reader the page is open. `open` would have brought it forward and shown + // the panel; this path has to do both itself. + previewTabs.select(open.id) + previewTabs.setCollapsed(false) + } else { + // Only the item's own page carries a form for this call, so a caller routing a call + // that was not asked to open anything gets a no rather than a new tab. + if (onlyIfAlreadyOpen) return false + previewTabs.open(viewTarget) + } + return true } - // Read off the tab list rather than the slot's lifecycle: a tab the user has switched - // away from is unmounted but still open, and the card must keep its form hidden until it - // is closed. A resolver, like activePreviewResolver: the reader's own $derived subscribes - // to `tabs` through it, and the runtime is not inside an effect root to push from. + manager.closeRunForm = (toolCallId) => { + // A page carrying the call outlives it, and stops carrying it because the call stops + // being pending. Only the chat's own form tab, which is the call, goes with it. + previewTabs.closeRunForm(toolCallId) + } + manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => { + const runHref = `/run/${jobId}?workspace=${workspace}` + // Either surface the call was waiting on becomes the run it started, in place. + const carrying = previewTabs.tabs.find((t) => pendingRunIdOf(t.url) === toolCallId) + if (carrying) previewTabs.retargetById(carrying.id, runHref) + previewTabs.retargetRunForm(toolCallId, runHref) + } + // Both read off the tab list rather than the slot's lifecycle: a tab the user has switched + // away from is unmounted but still open. Resolvers, like activePreviewResolver: the + // reader's own $derived subscribes to `tabs` through them, and the runtime is not inside + // an effect root to push from. So a tab closed or re-pointed hands the card back whatever + // it had given up. + // Either surface — the chat's own form in a tab, or the item's page carrying the call. manager.isRunFormInPreview = (toolCallId) => + previewTabs.tabs.some( + (t) => + parseRunFormRoute(t.url)?.toolCallId === toolCallId || pendingRunIdOf(t.url) === toolCallId + ) + // The chat's own form alone. The card's form is exclusive with this one and no other: + // both bind the one draft, and SchemaForm reorders in place the schema it is handed, so a + // second mount reorders it again. A page builds its own form from a copy of the proposal + // and shares nothing, so the card keeps its form while a page carries the call. + manager.isRunFormTabOpen = (toolCallId) => previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId) manager.openArtifact = (id, name, version) => { @@ -725,6 +823,7 @@ function createRuntime(session: Session): SessionRuntime { flowCell, loadedEditorPath, deployedRevision, + pendingRunFor, logTabUsage, async loadFlow(workspace: string, path: string, force = false) { From fe2571ea53603625f48601aa800e9f700322d5b8 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 14 Oct 2026 10:59:01 +0200 Subject: [PATCH 07/49] feat: list agents with runnables, let them be starred, and fill out their menu (#11651) * feat: star agents and add runs, audit logs and copy path to their menu Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * fix: list an agent's runs as the previews they are Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * feat: list agents in the merged runnables stream so they sort with everything else Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * perf: index agent paths so the merged listing and owner counts skip other resources Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * feat: find agents in the ctrl+k search Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * feat: open an agent's version history from its menu Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB --------- Co-authored-by: Claude Opus 5.5 --- ...009164611_add_agent_favorite_kind.down.sql | 1 + ...61009164611_add_agent_favorite_kind.up.sql | 8 + ...09173546_agent_owner_prefix_index.down.sql | 1 + ...1009173546_agent_owner_prefix_index.up.sql | 7 + backend/summarized_schema.txt | 2 +- backend/windmill-api/openapi.yaml | 29 ++- backend/windmill-api/src/db.rs | 3 + backend/windmill-api/src/favorite.rs | 1 + backend/windmill-api/src/runnables.rs | 169 +++++++++++++----- backend/windmill-store/src/resources.rs | 11 ++ .../components/common/table/AgentRow.svelte | 13 +- .../lib/components/common/table/Row.svelte | 2 +- .../lib/components/flows/AgentHistory.svelte | 34 ++++ .../src/lib/components/flows/agentActions.ts | 33 +++- .../src/lib/components/home/ItemsList.svelte | 160 +++-------------- .../search/GlobalSearchModal.svelte | 42 ++++- .../components/sidebar/FavoriteMenu.svelte | 5 +- .../src/routes/(root)/(logged)/+layout.svelte | 12 ++ .../agents/get/[...path]/+page.svelte | 4 + 19 files changed, 339 insertions(+), 198 deletions(-) create mode 100644 backend/migrations/20261009164611_add_agent_favorite_kind.down.sql create mode 100644 backend/migrations/20261009164611_add_agent_favorite_kind.up.sql create mode 100644 backend/migrations/20261009173546_agent_owner_prefix_index.down.sql create mode 100644 backend/migrations/20261009173546_agent_owner_prefix_index.up.sql create mode 100644 frontend/src/lib/components/flows/AgentHistory.svelte diff --git a/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql b/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql new file mode 100644 index 0000000000..c50ff19ab5 --- /dev/null +++ b/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql @@ -0,0 +1 @@ +DELETE FROM favorite WHERE favorite_kind = 'agent'; diff --git a/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql b/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql new file mode 100644 index 0000000000..c0959d2527 --- /dev/null +++ b/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql @@ -0,0 +1,8 @@ +DO +$do$ +BEGIN + ALTER TYPE FAVORITE_KIND ADD VALUE 'agent'; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'Couldn''t create FAVORITE_KIND::agent: %', SQLERRM; +END +$do$; diff --git a/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql b/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql new file mode 100644 index 0000000000..d3e9318571 --- /dev/null +++ b/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_resource_agent_owner_prefix; diff --git a/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql b/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql new file mode 100644 index 0000000000..be1fd5813f --- /dev/null +++ b/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql @@ -0,0 +1,7 @@ +-- Backs the agent branch of /runnables/list and /runnables/counts, which reach the +-- `ai_agent` rows of `resource` by workspace and byte-ordered path prefix. Partial, so +-- neither walks the workspace's other resources (state, cache, ...). +-- Created CONCURRENTLY via the OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs. +CREATE INDEX IF NOT EXISTS idx_resource_agent_owner_prefix + ON resource (workspace_id, path text_pattern_ops) + WHERE resource_type = 'ai_agent'; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index fa1c049746..6afe7bf30c 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -10,7 +10,7 @@ autoscaling_event_type: full_scaleout, scalein, scaleout aws_auth_resource_type: oidc, credentials delivery_mode: push, pull draft_type: script, flow, app -favorite_kind: app, script, flow, raw_app, asset +favorite_kind: app, script, flow, raw_app, asset, agent gcp_subscription_mode: create_update, existing http_method: get, post, put, delete, patch importer_kind: script, flow, app diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d124d34a65..d9064b2943 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9367,6 +9367,12 @@ paths: required: false schema: type: boolean + - name: starred_only + description: Only the `ai_agent` resources the caller starred + in: query + required: false + schema: + type: boolean responses: "200": description: resource list @@ -12783,7 +12789,7 @@ paths: /w/{workspace}/runnables/list: get: - summary: list runnables (scripts, flows, apps) merged, ordered and keyset-paginated + summary: list runnables (scripts, flows, apps, agents) merged, ordered and keyset-paginated operationId: listRunnables tags: - script @@ -12800,7 +12806,7 @@ paths: - $ref: "#/components/parameters/OrderDesc" - name: kinds in: query - description: comma-separated subset of script,flow,app (default all) + description: comma-separated subset of script,flow,app,agent (default all) schema: type: string - name: show_archived @@ -12874,7 +12880,7 @@ paths: - $ref: "#/components/parameters/WorkspaceId" - name: kinds in: query - description: comma-separated subset of script,flow,app (default all) + description: comma-separated subset of script,flow,app,agent (default all) schema: type: string - name: include_without_main @@ -24541,7 +24547,7 @@ paths: type: string favorite_kind: type: string - enum: [flow, app, script, raw_app, asset] + enum: [flow, app, script, raw_app, asset, agent] responses: "200": description: star item @@ -24564,7 +24570,7 @@ paths: type: string favorite_kind: type: string - enum: [flow, app, script, raw_app, asset] + enum: [flow, app, script, raw_app, asset, agent] responses: "200": description: unstar item @@ -30236,9 +30242,10 @@ components: description: | A row in the merged runnables listing. `type` is the discriminator; kind-specific fields (hash/language/kind for scripts, execution_mode/ - version for apps) are present only for that kind. `edited_at` is the - unified last-updated time (a script's created_at, a flow/app's edit - time). + version for apps, agent_memory/ws_specific for agents) are present only + for that kind. An agent is an `ai_agent` resource whose description is + reported as `summary`. `edited_at` is the unified last-updated time (a + script's created_at, a flow/app/agent's edit time). properties: type: type: string @@ -30246,6 +30253,7 @@ components: - script - flow - app + - agent path: type: string summary: @@ -30313,6 +30321,11 @@ components: version: type: integer format: int64 + agent_memory: + description: agent-only. The agent's `memory` setting. + ws_specific: + type: boolean + description: agent-only. Whether the agent is workspace-specific, which keeps it out of deploys to other workspaces. required: - type - path diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index db99a07c92..f269235460 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -113,6 +113,9 @@ lazy_static::lazy_static! { (20260909163047, include_str!( "../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql" ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")), + (20261009173546, include_str!( + "../../migrations/20261009173546_agent_owner_prefix_index.up.sql" + ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")), // The widening fails every query the previous release's still-running // servers prepared on the column; `variables::widen_variable_value` does it // once they are gone. diff --git a/backend/windmill-api/src/favorite.rs b/backend/windmill-api/src/favorite.rs index d3e3456e0f..86f3377b39 100644 --- a/backend/windmill-api/src/favorite.rs +++ b/backend/windmill-api/src/favorite.rs @@ -32,6 +32,7 @@ pub enum FavoriteKind { #[allow(non_camel_case_types)] Raw_App, Asset, + Agent, } #[derive(Deserialize)] pub struct Favorite { diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 7e64b48a1a..754221dac0 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -7,15 +7,17 @@ */ //! Unified, keyset-paginated listing of a workspace's runnables (scripts, -//! flows, apps) merged into one globally-ordered stream. The homepage uses it -//! so a chosen order (recently updated / oldest / name) is correct and complete -//! across all three kinds at any workspace size, instead of client-sorting a +//! flows, apps, and the `ai_agent` resources listed as agents) merged into one +//! globally-ordered stream. The homepage uses it so a chosen order (recently +//! updated / oldest / name) is correct and complete across every kind at any +//! workspace size, instead of client-sorting a //! per-kind capped window. //! //! Efficiency: each kind is a UNION ALL branch ordered by an index on //! `(workspace_id, archived, )` (created_at / edited_at, or the lowered //! summary-or-path expression for name orders); Postgres merges the ordered -//! branches and stops at the page limit. Pagination is keyset — a +//! branches and stops at the page limit. Agents, which are few, are instead +//! reached through the partial `idx_resource_agent_owner_prefix` and sorted whole. Pagination is keyset — a //! `(sort_key, path, kind, tiebreak)` cursor, where `tiebreak` (a script's hash, //! 0 for flow/app) is a stable final key that keeps the order total even when rows //! tie on (sort_key, path, kind) — so deep pages don't re-scan. Visibility is @@ -51,7 +53,7 @@ struct ListRunnablesQuery { order_by: Option, /// Descending when true (default true). order_desc: Option, - /// Comma-separated subset of `script,flow,app`; omitted means all. + /// Comma-separated subset of `script,flow,app,agent`; omitted means all. kinds: Option, show_archived: Option, /// Include library scripts (no runnable main). Ignored for flows/apps. @@ -80,7 +82,7 @@ struct ListRunnablesQuery { #[derive(Serialize, sqlx::FromRow)] struct RunnableItem { #[serde(rename = "type")] - kind: String, // 'script' | 'flow' | 'app' + kind: String, // 'script' | 'flow' | 'app' | 'agent' path: String, #[serde(skip_serializing_if = "Option::is_none")] summary: Option, @@ -130,6 +132,11 @@ struct RunnableItem { id: Option, #[serde(skip_serializing_if = "Option::is_none")] version: Option, + // agent-only: the `memory` setting, which decides whether the agent opens as a chat. + #[serde(skip_serializing_if = "Option::is_none")] + agent_memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ws_specific: Option, // sort keys, echoed into the cursor (not serialized to the client) #[serde(skip)] sort_time: chrono::DateTime, @@ -247,7 +254,7 @@ fn scope_path_predicate( } } -/// The three UNION-ALL branch SELECTs, each projecting the shared `RunnableItem` +/// The UNION-ALL branch SELECTs, each projecting the shared `RunnableItem` /// column set (NULL for columns that don't apply to that kind). `$1`=workspace, /// `$2`=username (favorites), `$3`=email (drafts). Kind-specific and per-request /// WHERE fragments are appended by the caller. @@ -255,6 +262,7 @@ struct Branches { script: String, flow: String, app: String, + agent: String, } fn branch_sqls() -> Branches { @@ -281,6 +289,7 @@ fn branch_sqls() -> Branches { o.codebase IS NOT NULL as use_codebase, \ (o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \ NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \ FROM script o \ LEFT JOIN favorite ON favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -298,6 +307,7 @@ fn branch_sqls() -> Branches { NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \ (o.value->>'chat_input_enabled')::bool as chat_input_enabled, \ NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ FROM flow o \ LEFT JOIN favorite ON favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -315,6 +325,7 @@ fn branch_sqls() -> Branches { NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \ av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \ o.versions[array_upper(o.versions, 1)] as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ FROM app o \ LEFT JOIN favorite ON favorite.favorite_kind = 'app' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -323,25 +334,68 @@ fn branch_sqls() -> Branches { draft_users = draft_users("d.typ IN ('app', 'raw_app')") ); - Branches { script, flow, app } + // An agent is an `ai_agent` resource; the subquery renames its description to the + // `summary` the shared search and name sort read. + let agent = format!( + "SELECT 'agent' as kind, o.path, o.summary, o.workspace_id, o.extra_perms, \ + favorite.path IS NOT NULL as starred, false as archived, \ + draft.email IS NOT NULL as is_draft, NULL::bool as draft_only, NULL::text as draft_path, \ + {draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \ + NULL::bool as ws_error_handler_muted, o.edited_at, \ + NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ + NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \ + NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + o.agent_memory, ws_specific.path IS NOT NULL as ws_specific, \ + COALESCE(o.edited_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ + FROM (SELECT workspace_id, path, description as summary, extra_perms, labels, edited_at, \ + value->'memory' as agent_memory \ + FROM resource WHERE resource_type = 'ai_agent') o \ + LEFT JOIN favorite ON favorite.favorite_kind = 'agent' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ + LEFT JOIN draft ON draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'resource' AND draft.email = $3 \ + LEFT JOIN ws_specific ON ws_specific.path = o.path AND ws_specific.workspace_id = o.workspace_id AND ws_specific.item_kind = 'resource'", + draft_users = draft_users("d.typ = 'resource'") + ); + + Branches { script, flow, app, agent } +} + +/// The draft rows a kind lists and the table whose rows make a draft not draft-only. +fn draft_kind_source(kind: &str) -> (&'static str, &'static str) { + match kind { + "script" => ("d.typ = 'script'", "script"), + "flow" => ("d.typ = 'flow'", "flow"), + // Typed by the draft itself, as the resource listing filters its draft-only rows. + "agent" => ( + "d.typ = 'resource' AND d.value->>'resource_type' = 'ai_agent'", + "resource", + ), + _ => ("d.typ IN ('app', 'raw_app')", "app"), + } +} + +/// Where a draft records the path its editor has staged. Scripts and resources bind the +/// Path widget to the item's own `path`, so it round-trips through the draft JSON's +/// `path`; flows and apps write a separate `draft_path` only when it differs from the +/// deployed one. See scripts.rs. +fn draft_typed_path(kind: &str) -> &'static str { + if kind == "script" || kind == "agent" { + "path" + } else { + "draft_path" + } } /// The draft-only branch for a kind: the caller's drafts at paths carrying no /// deployed row, projected into the same column set as `branch_sqls` so they /// sort, search and paginate as ordinary rows. Same `$1`/`$2`/`$3` contract. fn draft_branch_sql(kind: &str) -> String { - let (typ_pred, deployed) = match kind { - "script" => ("d.typ = 'script'", "script"), - "flow" => ("d.typ = 'flow'", "flow"), - _ => ("d.typ IN ('app', 'raw_app')", "app"), - }; - // Scripts bind the Path widget to `script.path`, so the typed path round-trips - // through the draft JSON's own `path`; flows and apps write a separate - // `draft_path` only when it differs from the deployed one. See scripts.rs. - let typed_path = if kind == "script" { - "path" + let (typ_pred, deployed) = draft_kind_source(kind); + let typed_path = draft_typed_path(kind); + // A resource draft carries the description where the other kinds carry a summary. + let summary_field = if kind == "agent" { + "description" } else { - "draft_path" + "summary" }; // `auto_kind` is only what the editor stamped into the draft — a `// pipeline` // annotation is not re-derived from the content here, unlike the per-kind @@ -351,7 +405,8 @@ fn draft_branch_sql(kind: &str) -> String { "script" => { "d.value->>'language' as language, d.value->>'kind' as script_kind, \ d.value->>'auto_kind' as auto_kind, false as raw_app, \ - NULL::bool as chat_input_enabled" + NULL::bool as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" } // Type-guarded rather than a bare `::bool` cast: draft JSON is stored // unvalidated, so a malformed value must yield NULL, not abort the list, @@ -360,11 +415,20 @@ fn draft_branch_sql(kind: &str) -> String { "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ false as raw_app, \ CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \ - THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled" + THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" + } + "agent" => { + "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ + false as raw_app, NULL::bool as chat_input_enabled, \ + (d.value->'args'->'memory')::jsonb as agent_memory, \ + CASE WHEN json_typeof(d.value->'wsSpecific') = 'boolean' \ + THEN (d.value->>'wsSpecific')::bool END as ws_specific" } _ => { "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ - (d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled" + (d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" } }; format!( @@ -378,11 +442,12 @@ fn draft_branch_sql(kind: &str) -> String { NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \ NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \ o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + o.agent_memory, o.ws_specific, \ o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \ FROM ( \ SELECT DISTINCT ON (d.path) d.workspace_id, d.path, d.created_at, \ d.email IS NULL as legacy, \ - COALESCE(d.value->>'summary', '') as summary, \ + COALESCE(d.value->>'{summary_field}', '') as summary, \ NULLIF(NULLIF(d.value->>'{typed_path}', ''), d.path) as draft_path, \ {kind_cols} \ FROM draft d \ @@ -397,6 +462,8 @@ fn draft_branch_sql(kind: &str) -> String { ) } +const ALL_KINDS: [&str; 4] = ["script", "flow", "app", "agent"]; + async fn list_runnables( authed: ApiAuthed, Extension(user_db): Extension, @@ -416,16 +483,16 @@ async fn list_runnables( }; let mut kinds: Vec<&str> = match q.kinds.as_deref() { - None | Some("") => vec!["script", "flow", "app"], + None | Some("") => ALL_KINDS.to_vec(), Some(csv) => csv .split(',') .map(|s| s.trim()) - .filter(|s| ["script", "flow", "app"].contains(s)) + .filter(|s| ALL_KINDS.contains(s)) .collect(), }; - // Apps carry no `archived` column and are never listed as archived. + // Apps and agents carry no `archived` column and are never listed as archived. if show_archived { - kinds.retain(|k| *k != "app"); + kinds.retain(|k| *k != "app" && *k != "agent"); } let branches = branch_sqls(); @@ -533,6 +600,11 @@ async fn list_runnables( } else { None }; + let agent_scope = if kinds.contains(&"agent") { + scope_path_predicate(&authed, "resources", "o", 3, &mut binds) + } else { + None + }; // Per-kind archived predicate (scripts/flows have the column; apps don't and // are excluded from the archived view). @@ -570,6 +642,10 @@ async fn list_runnables( if let Some(s) = &app_scope { app_extras.push(s.clone()); } + let mut agent_extras: Vec = vec![]; + if let Some(s) = &agent_scope { + agent_extras.push(s.clone()); + } // Draft-only rows are the caller's own work in progress: never archived, so they // have no place in the archived view, and carrying no labels of their own they are @@ -589,6 +665,7 @@ async fn list_runnables( let scope = match kind { "script" => &script_scope, "flow" => &flow_scope, + "agent" => &agent_scope, _ => &app_scope, }; // The lib filter reads the same projected `auto_kind`, so a draft-only library @@ -649,6 +726,7 @@ async fn list_runnables( "script" => (&branches.script, &script_extras), "flow" => (&branches.flow, &flow_extras), "app" => (&branches.app, &app_extras), + "agent" => (&branches.agent, &agent_extras), _ => return None, }; Some(build_branch( @@ -708,7 +786,7 @@ async fn list_runnables( // No draft branch here: a draft-only path has no favorite row (the UI won't let // you star one), so it would scan the caller's whole draft slice per kind to // return nothing. The main stream below takes them unfiltered instead. - let starred_branches: Vec = ["script", "flow", "app"] + let starred_branches: Vec = ALL_KINDS .iter() .filter_map(|k| branch_for(k, Some(true), None, None)) .collect(); @@ -728,10 +806,10 @@ async fn list_runnables( // Main paged stream: non-starred rows (starred were pinned on the first page above). let main_fav = Some(false); let ns_branches: Vec = - ["script", "flow", "app"] + ALL_KINDS .iter() .filter_map(|k| branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page))) - .chain(["script", "flow", "app"].iter().filter_map(|k| { + .chain(ALL_KINDS.iter().filter_map(|k| { draft_branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page)) })) .collect(); @@ -762,7 +840,7 @@ async fn list_runnables( #[derive(Deserialize)] struct CountRunnablesQuery { - /// Comma-separated subset of `script,flow,app`; omitted means all. + /// Comma-separated subset of `script,flow,app,agent`; omitted means all. kinds: Option, /// Include library scripts (no runnable main). Ignored for flows/apps. include_without_main: Option, @@ -789,7 +867,7 @@ struct OwnerCount { /// `owner/` is >= `owner || '/'` and, since '/' (0x2F) is immediately /// followed by '0' (0x30), < `owner || '0'`. `~>=~` / `~<~` are the /// text_pattern_ops operators, which is what lets `idx__owner_prefix` -/// answer the count with an index-only scan — the default opclass sorts by the +/// (`idx_resource_agent_owner_prefix` for agents) answer the count with an index-only scan — the default opclass sorts by the /// database collation and cannot serve a byte-prefix range. fn owner_prefix_range(alias: &str) -> String { format!("{alias}.path ~>=~ (o.owner || '/') AND {alias}.path ~<~ (o.owner || '0')") @@ -818,13 +896,13 @@ async fn count_runnables_by_owner( Query(q): Query, ) -> JsonResult { let kinds: Vec<&str> = match q.kinds.as_deref() { - None | Some("") => vec!["script", "flow", "app"], + None | Some("") => ALL_KINDS.to_vec(), // Deduplicated: every kind becomes its own count subquery, so a repeated // entry would both double that kind's count and multiply the scans. Some(csv) => { let mut ks: Vec<&str> = vec![]; for k in csv.split(',').map(|s| s.trim()) { - if ["script", "flow", "app"].contains(&k) && !ks.contains(&k) { + if ALL_KINDS.contains(&k) && !ks.contains(&k) { ks.push(k); } } @@ -866,6 +944,13 @@ async fn count_runnables_by_owner( w.push(s); } } + "agent" => { + w.push(format!("{alias}.resource_type = 'ai_agent'")); + if let Some(s) = scope_path_predicate(&authed, "resources", alias, base, binds) + { + w.push(s); + } + } _ => { if let Some(s) = scope_path_predicate(&authed, "apps", alias, base, binds) { w.push(s); @@ -877,6 +962,7 @@ async fn count_runnables_by_owner( let table_of = |kind: &str| match kind { "script" => "script", "flow" => "flow", + "agent" => "resource", _ => "app", }; @@ -1048,16 +1134,17 @@ async fn add_draft_counts( let branches: Vec = kinds .iter() .map(|kind| { - let (typ_pred, deployed, domain) = match *kind { - "script" => ("d.typ = 'script'", "script", "scripts"), - "flow" => ("d.typ = 'flow'", "flow", "flows"), - _ => ("d.typ IN ('app', 'raw_app')", "app", "apps"), + let (typ_pred, deployed) = draft_kind_source(kind); + let domain = match *kind { + "script" => "scripts", + "flow" => "flows", + "agent" => "resources", + _ => "apps", }; // The owner a draft counts under is where it says it will live, not the // `u//draft_` it is parked at — same path `/list` groups and - // filters on. Scripts round-trip the typed path through the draft JSON's - // own `path`; flows and apps write `draft_path`. See scripts.rs. - let typed_path = if *kind == "script" { "path" } else { "draft_path" }; + // filters on. + let typed_path = draft_typed_path(kind); let effective_path = format!("COALESCE(NULLIF(d.value->>'{typed_path}', ''), d.path) as path"); let mut w = vec![ diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index eeea790009..19ba8c529b 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -261,6 +261,8 @@ pub struct ListResourceQuery { /// When true, append per-user draft-only rows; picker callers leave it off /// to stay deployed-only. See list synthesis in scripts.rs. pub include_draft_only: Option, + /// Only the `ai_agent` resources the authed user starred. + pub starred_only: Option, } #[derive(Serialize, FromRow)] @@ -437,6 +439,14 @@ async fn list_resources( ); } + if lq.starred_only.unwrap_or(false) { + sqlb.and_where( + "EXISTS(SELECT 1 FROM favorite WHERE favorite.workspace_id = resource.workspace_id \ + AND favorite.path = resource.path AND favorite.favorite_kind = 'agent' AND favorite.usr = ?)" + .bind(&authed.username), + ); + } + if let Some(label) = &lq.label { for l in label.split(',') { sqlb.and_where( @@ -472,6 +482,7 @@ async fn list_resources( && lq.value.is_none() && lq.broad_filter.is_none() && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) { let rt_filter: Option> = lq .resource_type diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index aca6ae7f9c..05c15a582a 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -13,6 +13,7 @@ import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte' import { keepsManagedMemory } from '$lib/components/flows/agentFormFields' import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions' + import AgentHistory from '$lib/components/flows/AgentHistory.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import Row from './Row.svelte' import { selectMenuItems, type RowSelection } from './rowSelection' @@ -54,8 +55,16 @@ let editHref = $derived(`/agents/edit/${agent.path}`) // A draft-only agent has nothing deployed for its page to run, so it opens in the editor. let rowHref = $derived(agent.draft_only ? editHref : `/agents/get/${agent.path}`) + let history: AgentHistory | undefined = $state(undefined) + dispatch('change')} +/> + {#snippet chatBadge()} {/snippet} @@ -69,7 +78,7 @@ path={agent.draft_path ?? agent.path} summary={agent.description} workspaceId={agent.workspace_id ?? $workspaceStore ?? ''} - canFavorite={false} + canFavorite={!agent.draft_only} {depth} {rowSelection} > @@ -110,8 +119,10 @@ canWrite: agent.canWrite, draftOnly: Boolean(agent.draft_only), wsSpecific: agent.ws_specific, + operator: $userStore?.operator, onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'), onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'), + onHistory: () => history?.open(), onDelete: (event) => { const { path } = agent if (event?.shiftKey) remove(path) diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 76cd30640a..111618145f 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -260,7 +260,7 @@ {/if} - {#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow')} + {#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow' || kind == 'agent')}
diff --git a/frontend/src/lib/components/flows/AgentHistory.svelte b/frontend/src/lib/components/flows/AgentHistory.svelte new file mode 100644 index 0000000000..c6deff1e59 --- /dev/null +++ b/frontend/src/lib/components/flows/AgentHistory.svelte @@ -0,0 +1,34 @@ + + + + drawer?.closeDrawer()} noPadding> + { + drawer?.closeDrawer() + // A restore writes the resource as a deploy does, so whatever shows this agent re-reads it. + markAgentWritten($workspaceStore ?? undefined, path) + onRestored?.() + }} + /> + + diff --git a/frontend/src/lib/components/flows/agentActions.ts b/frontend/src/lib/components/flows/agentActions.ts index daaec527df..da31a3665f 100644 --- a/frontend/src/lib/components/flows/agentActions.ts +++ b/frontend/src/lib/components/flows/agentActions.ts @@ -1,8 +1,9 @@ -import { FileUp, Shield, Trash } from 'lucide-svelte' +import { Copy, Eye, FileUp, History, List, Shield, Trash } from 'lucide-svelte' import { ResourceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' -import type { Item } from '$lib/utils' +import { copyToClipboard, type Item } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' +import { goto } from '$lib/navigation' import type { WorkspaceDeployUISettings } from '$lib/gen' /** Deletes a saved agent, saying how it went. Resolves whether it was deleted. */ @@ -24,10 +25,13 @@ export function agentMenuItems(agent: { /** Never deployed: no resource yet to hold permissions or to deploy onward. */ draftOnly?: boolean wsSpecific?: boolean + /** Hides what an operator has no page for, as on a flow's menu. */ + operator?: boolean /** Undefined until loaded, when nothing is offered for deploy. */ deployUiSettings: WorkspaceDeployUISettings | undefined onPermissions: () => void onDeploy: () => void + onHistory: () => void onDelete: (event?: MouseEvent) => void }): Item[] { const deployable = @@ -35,6 +39,28 @@ export function agentMenuItems(agent: { !agent.draftOnly && isDeployable('resource', agent.path, agent.deployUiSettings) return [ + // A draft-only agent has no runs, no audit trail, and only a placeholder path yet. + ...(agent.draftOnly + ? [] + : [ + { + // An agent run is a flow preview, which the runs page hides by default. Chat turns + // are filed under `.chat` (agent_runs.rs) and listed by the agent's own chat. + displayName: 'View runs', + icon: List, + action: () => goto(`/runs/${agent.path}?job_kinds=previews`) + }, + ...(agent.operator + ? [] + : [ + { + displayName: 'Audit logs', + icon: Eye, + action: () => goto(`/audit_logs?resource=${agent.path}`) + } + ]), + { displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) } + ]), { displayName: 'Permissions', icon: Shield, @@ -44,6 +70,9 @@ export function agentMenuItems(agent: { ...(deployable ? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }] : []), + ...(agent.draftOnly || agent.operator + ? [] + : [{ displayName: 'Version history', icon: History, action: agent.onHistory }]), { displayName: 'Delete', icon: Trash, diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index ee22e1a43a..7e71cd5a02 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -10,7 +10,6 @@ type ListableApp, type Script, ScriptService, - ResourceService, type Flow, type ListableRawApp, type ListableResource, @@ -175,12 +174,7 @@ let itemKind = $derived( (filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'agent' | 'all' ) - // Agents are `ai_agent` resources, listed through the resource endpoint: the runnables - // endpoint has no such kind, so it is asked for nothing on the agent view and for every - // kind on the combined one. - let showsRunnables = $derived(itemKind !== 'agent') - let showsAgents = $derived(itemKind === 'all' || itemKind === 'agent') - let runnableKinds = $derived(itemKind === 'all' || itemKind === 'agent' ? undefined : itemKind) + let runnableKinds = $derived(itemKind === 'all' ? undefined : itemKind) let archived = $derived(!!filterValues.val.archived) let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean) let filterUserFolders = $derived(!!filterValues.val.only_user_folders) @@ -318,11 +312,7 @@ let flows: TableFlow[] | undefined = $state() let apps: TableApp[] | undefined = $state() let raw_apps: TableRawApp[] | undefined = $state() - // Starts empty rather than undefined: the runnables page decides when the list renders, - // and agents join it when their own request lands. - let agents: TableAgent[] = $state([]) - let agentOwnerCounts: Record = $state({}) - let agentsGen = 0 + let agents: TableAgent[] | undefined = $state() // Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord). let fetchOrd = 0 @@ -353,7 +343,7 @@ } } - function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp { + function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp | TableAgent { const base = { ...it, canWrite: @@ -367,84 +357,14 @@ // combinedItems reads a script's time from `created_at`; the endpoint's // unified `edited_at` holds exactly that for scripts. if (it.type === 'script') return { ...base, created_at: it.edited_at } as unknown as TableScript + // An agent row reads its resource's description, which the endpoint reports as `summary`. + if (it.type === 'agent') return { ...base, description: it.summary } as unknown as TableAgent return base as unknown as TableFlow | TableApp } - /** - * The workspace's saved agents, as the kind the home page shows them as. Read from the - * resource listing, which the runnables' keyset order knows nothing of, so the order is applied - * here and the rows are stamped before every runnable ordinal: the agents lead as one block and - * a page of runnables keeps the server's order. Leading rather than trailing, or the list's - * first window would never reach them past a page of runnables. - */ - async function loadAgents(): Promise { - const ws = $workspaceStore - const gen = ++agentsGen - if (!ws || !$userStore || !showsAgents || archived) { - agents = [] - agentOwnerCounts = {} - return - } - const rows: ListableResource[] = [] - try { - // Paged even though search and scoping run over the whole set here: the listing caps a - // page, and only the first carries the draft-only rows, so a short page ends it. - const perPage = 1000 - for (let page = 1; ; page++) { - const batch = await ResourceService.listResource({ - workspace: ws, - resourceType: 'ai_agent', - includeDraftOnly: true, - page, - perPage - }) - rows.push(...batch) - if (batch.length < perPage || gen !== agentsGen) break - } - } catch (e: any) { - if (gen !== agentsGen) return - sendUserToast(`Failed to load agents: ${e?.body ?? e?.message ?? e}`, true) - agents = [] - agentOwnerCounts = {} - return - } - if (gen !== agentsGen) return - // Counted before the owner scope: the chips and tree nodes list every owner. - const counts: Record = {} - for (const r of rows) { - const owner = effectivePath(r).split('/').slice(0, 2).join('/') - counts[owner] = (counts[owner] ?? 0) + 1 - } - agentOwnerCounts = counts - const scoped = ownerFilter - ? rows.filter((r) => effectivePath(r).startsWith(ownerFilter + '/')) - : rows - const byTime = (r: ListableResource) => new Date(r.edited_at ?? 0).getTime() - const sorted = [...scoped].sort((a, b) => { - switch (sortOrder) { - case 'updated_asc': - return byTime(a) - byTime(b) - case 'name_asc': - return cmp(effectivePath(a), effectivePath(b)) - case 'name_desc': - return cmp(effectivePath(b), effectivePath(a)) - default: - return byTime(b) - byTime(a) - } - }) - agents = sorted.map((r, i) => ({ - ...r, - summary: r.description || undefined, - canWrite: editRights.canEditItem('agent', r.path, (r.extra_perms ?? {}) as any), - ord: AGENT_ORD_BASE + i - })) - } - /** Below any ordinal a runnables page stamps (they count up from 0), so agents lead as a block. */ - const AGENT_ORD_BASE = -1_000_000 - // The merged, server-ordered, keyset-paginated source. `reset` reloads from // the first page (order/filter change or workspace switch); otherwise it - // appends the next page. All three kinds arrive interleaved and are split into + // appends the next page. Every kind arrives interleaved and are split into // the existing per-kind arrays so the downstream pipeline is unchanged. async function loadRunnables(reset: boolean): Promise { const ws = $workspaceStore @@ -458,25 +378,6 @@ // arrays (mixing streams) or clobber the pending reset's generation. if (!reset && serverCursor === undefined) return if (scripts === undefined) loading = true - // One load per reset: agents are fetched whole, so a load-more has nothing to append. - const agentsLoad = reset ? loadAgents() : undefined - if (!showsRunnables) { - // Supersedes a page still in flight, which would otherwise land in the agent view. - const gen = ++loadGen - serverCursor = undefined - hasMoreServer = false - // The rows on screen stay until the agents replace them, as a reload keeps them, or the - // view reads as empty in between. - await agentsLoad - if (gen !== loadGen) return - scripts = [] - flows = [] - apps = [] - raw_apps = [] - pipelineMemberFolders = new Set() - loading = false - return - } if (reset) { serverCursor = undefined hasMoreServer = false @@ -519,18 +420,13 @@ // A newer request superseded this one (e.g. order changed mid-flight); drop // this response so a stale page/cursor can't be mixed with the new order. if (gen !== loadGen) return - // Landed together with the agents fetched beside it, or a workspace holding only agents - // reads as empty until they arrive. - if (agentsLoad) { - await agentsLoad - if (gen !== loadGen) return - } serverCursor = res.next_cursor ?? undefined hasMoreServer = !!res.next_cursor const s: TableScript[] = reset ? [] : [...(scripts ?? [])] const f: TableFlow[] = reset ? [] : [...(flows ?? [])] const a: TableApp[] = reset ? [] : [...(apps ?? [])] + const ag: TableAgent[] = reset ? [] : [...(agents ?? [])] const memberFolders = reset ? new Set() : new Set(pipelineMemberFolders) if (reset) fetchOrd = 0 for (const it of res.items ?? []) { @@ -546,11 +442,14 @@ f.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableFlow) } else if (it.type === 'app') { a.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableApp) + } else if (it.type === 'agent') { + ag.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableAgent) } } scripts = s flows = f apps = a + agents = ag raw_apps = [] pipelineMemberFolders = memberFolders loading = false @@ -564,10 +463,13 @@ // skipping ones already present. Lets a search reach matches outside the loaded // browse window without re-fetching the whole list. function mergeRunnables(newItems: RunnableItem[]) { - const have = new Set([...(scripts ?? []), ...(flows ?? []), ...(apps ?? [])].map(itemKey)) + const have = new Set( + [...(scripts ?? []), ...(flows ?? []), ...(apps ?? []), ...(agents ?? [])].map(itemKey) + ) const s = [...(scripts ?? [])] const f = [...(flows ?? [])] const a = [...(apps ?? [])] + const ag = [...(agents ?? [])] const memberFolders = new Set(pipelineMemberFolders) let changed = false for (const it of newItems) { @@ -583,12 +485,14 @@ if (it.type === 'script') s.push(mapped as TableScript) else if (it.type === 'flow') f.push(mapped as TableFlow) else if (it.type === 'app') a.push(mapped as TableApp) + else if (it.type === 'agent') ag.push(mapped as TableAgent) changed = true } if (changed) { scripts = s flows = f apps = a + agents = ag pipelineMemberFolders = memberFolders } } @@ -656,13 +560,6 @@ // Track the prefix as open first — even a no-op call (re-expanding a cached node) // means it's on screen, so later reloads must refresh it. openOwners.add(owner) - // The agent view has no runnables to page in: its rows are the loaded agents, which - // `treeSource` adds itself. Rows an owner held from another kind go with the switch. - if (!showsRunnables) { - treeOwnerItems = treeOwnerItems.filter((x) => !effectivePath(x).startsWith(`${owner}/`)) - ownerLoad[owner] = { hasMore: false, loading: false, loaded: true, gen: treeGen } - return - } const st = ownerLoad[owner] // Only a load for the CURRENT generation blocks a new one. A load left in flight // by a superseded generation (treeGen bumped on a sort/filter reload) has already @@ -1105,7 +1002,6 @@ [() => $workspaceStore, () => archived, () => itemKind, () => includeWithoutMain], async ([ws, showArchived, kind, withoutMain]) => { if (!ws || showArchived) return undefined - if (kind === 'agent') return {} try { const res = await ScriptService.countRunnablesByOwner({ workspace: ws, @@ -1121,16 +1017,7 @@ } } ) - // The runnables endpoint never counts an agent, so the loaded agents are added per owner. - let ownerCounts = $derived.by(() => { - const runnables = ownerCountsRes.current - if (runnables == undefined) return undefined - const counts = { ...runnables } - for (const [owner, n] of Object.entries(agentOwnerCounts)) { - counts[owner] = (counts[owner] ?? 0) + n - } - return counts - }) + let ownerCounts = $derived(ownerCountsRes.current) // The counts decide which owners the tree renders, so drawing it before they land // would show every workspace folder and then prune it away. Hold the skeleton // until the first response instead — it is fetched in parallel with the listing, @@ -1314,8 +1201,7 @@ const kind = itemKind // Any term/scope change restarts search paging. searchCursor = undefined - // Agents are all loaded, so the client-side search over them is already complete. - if (term === '' || !ws || !$userStore || kind === 'agent') return + if (term === '' || !ws || !$userStore) return const handle = setTimeout(async () => { let res: { items: RunnableItem[]; next_cursor?: string } try { @@ -1371,7 +1257,7 @@ search: term, showArchived: showArchived ? true : undefined, includeWithoutMain: withoutMain ? true : undefined, - kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined, + kinds: kind !== 'all' ? kind : undefined, pathStart: owner ? owner + '/' : undefined, includeDraftOnly: true, perPage: 1000, @@ -1423,7 +1309,7 @@ type: 'raw_app' as 'raw_app', time: new Date(x.edited_at).getTime() })), - ...agents.map((x) => ({ + ...(agents ?? []).map((x) => ({ ...x, type: 'agent' as 'agent', time: new Date(x.edited_at ?? 0).getTime() @@ -1487,12 +1373,10 @@ // injected as a node and its rows come from the on-demand `treeOwnerItems` store, // so the tree never depends on which owners happen to be in the loaded window. // Otherwise (scoped/search/label) the tree just groups the global `items`. - // Agents ride along in lazy mode too: the owner store is fed by the runnables endpoint, - // which never returns one, while the resource listing already holds them all. let treeSource = $derived( treeLazyMode - ? [...treeOwnerItems, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter( - (x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) + ? treeOwnerItems.filter((x) => + filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) ) : items ) diff --git a/frontend/src/lib/components/search/GlobalSearchModal.svelte b/frontend/src/lib/components/search/GlobalSearchModal.svelte index b94e0faefa..c2c4a15bd8 100644 --- a/frontend/src/lib/components/search/GlobalSearchModal.svelte +++ b/frontend/src/lib/components/search/GlobalSearchModal.svelte @@ -3,16 +3,19 @@ import { AppService, FlowService, + ResourceService, ScriptService, type Flow, type ListableApp, type ListableRawApp, + type ListableResource, type Script, type SearchJobsIndexResponse } from '$lib/gen' import { clickOutside, isMac, scroll_into_view_if_needed_polyfill } from '$lib/utils' import { AlertTriangle, + Bot, BoxesIcon, CalendarIcon, Code2Icon, @@ -432,6 +435,9 @@ case 'raw_app': path = `/apps_raw/get/${e.path}` break + case 'agent': + path = `/agents/get/${e.path}` + break default: path = '/' } @@ -501,7 +507,7 @@ } } - type TableItem = T & { + type TableItem = T & { search_id: string marked?: string type?: U @@ -518,7 +524,9 @@ type TableApp = TableItem type TableRawApp = TableItem - type TableAny = TableScript | TableFlow | TableApp | TableRawApp + type TableAgent = TableItem + + type TableAny = TableScript | TableFlow | TableApp | TableRawApp | TableAgent let combinedItems: TableAny[] | undefined = $state(undefined) @@ -532,8 +540,18 @@ withoutDescription: true }) const apps = await AppService.listApps({ workspace: $workspaceStore! }) + const [agents, starredAgents] = await Promise.all( + [false, true].map((starredOnly) => + ResourceService.listResource({ + workspace: $workspaceStore!, + resourceType: 'ai_agent', + starredOnly + }) + ) + ) + const starredAgentPaths = new Set(starredAgents.map((x) => x.path)) - let combinedItems: (TableScript | TableFlow | TableApp | TableRawApp)[] | undefined = [ + let combinedItems: TableAny[] | undefined = [ ...flows.map((x) => ({ ...x, type: 'flow' as 'flow', @@ -555,7 +573,17 @@ type: 'app' as 'app', time: new Date(x.edited_at).getTime(), search_id: x.path - })) + })), + ...agents + .filter((x) => !x.draft_only) + .map((x) => ({ + ...x, + summary: x.description, + type: 'agent' as 'agent', + time: x.edited_at ? new Date(x.edited_at).getTime() : 0, + starred: starredAgentPaths.has(x.path), + search_id: x.path + })) ].sort((a, b) => (a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1)) return combinedItems @@ -571,6 +599,8 @@ return LayoutDashboardIcon case 'raw_app': return LayoutDashboardIcon + case 'agent': + return Bot } } @@ -729,7 +759,9 @@ {#if tab === 'default'} {#if (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)).length > 0}
-
Flows/Scripts/Apps
+
+ Flows/Scripts/Apps/Agents
{#each (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)) as el} { diff --git a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte index 1c3191f0cb..c3d14d4c4d 100644 --- a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte +++ b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte @@ -10,6 +10,7 @@ flow: `/flows/get/${path}`, app: `/apps/get/${path}`, raw_app: `/apps_raw/get/${path}`, + agent: `/agents/get/${path}`, asset: '#' }[kind] } @@ -71,7 +72,7 @@ + +
+

+ The workspace settings in the layout of the settings.yaml file that + wmill sync reads and writes. Deleting a key clears that setting. Secrets show as + __SENSITIVE_AND_UNCHANGED__: leave the placeholder to keep the stored value. +

+ + {#each READ_ONLY_KEYS as key, i (key)}{key}{i < READ_ONLY_KEYS.length - 1 + ? ', ' + : ''}{/each} are shown for reference and never applied: change the name from the General pane + and connect Slack from the Slack pane. + {#each NOT_CLEARABLE_KEYS as key, i (key)}{key}{i < NOT_CLEARABLE_KEYS.length - 1 + ? ' and ' + : ''}{/each} are applied when set, but deleting them does not clear them. + + +
+ {#if loading && !initialCode} + + {:else} + {#await import('$lib/components/SimpleEditor.svelte')} + + {:then Module} + + {/await} + {/if} +
+ + {#if loadError} + {loadError} + {/if} + {#if parseError} + {parseError} + {/if} + {#if applyError} + {applyError} + {/if} + {#each warnings as warning, i (i)} + {warning} + {/each} + + +
+ + +
+ {#each pendingChanges as change (change.key)} +
+ {change.key} + + {formatValue(change.before)} → {formatValue(change.after)} + +
+ {/each} +
+
diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index d8e3d05d35..9ce48e63f8 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -97,6 +97,8 @@ import SettingsFooter from '$lib/components/workspaceSettings/SettingsFooter.svelte' import WorkspaceRulesets from '$lib/components/workspaceSettings/WorkspaceRulesets.svelte' import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' + import WorkspaceSettingsYaml from '$lib/components/workspaceSettings/WorkspaceSettingsYaml.svelte' + import { loadCopilot } from '$lib/components/copilot/loadCopilot' let slackInitialPath: string = $state('') let slackScriptPath: string = $state('') @@ -384,6 +386,50 @@ // something for a fork. A root workspace deploys nowhere by lineage. const showDeployToTab = $derived(Boolean(currentWorkspace?.parent_workspace_id)) const canAdmin = $derived(($userStore?.is_admin ?? false) || Boolean($superadmin)) + let yamlMode = $state(false) + // A workspace where the user is not an admin hides the toggle, so it must not stay in YAML mode. + const showYaml = $derived(yamlMode && canAdmin) + let yamlEditor: WorkspaceSettingsYaml | undefined = $state(undefined) + let yamlConfirm: 'enter' | 'leave' | undefined = $state(undefined) + + function paneHasUnsavedChanges() { + const changes = getAllUnsavedChanges() + return hasUnsavedChanges(changes.savedValue ?? {}, changes.modifiedValue ?? {}) + } + + // Switching mode unmounts either the editor or the current pane, so the check runs in the + // binding's setter, before that happens. A refused change still leaves the toggle's checkbox + // flipped, so the toggle is remounted to show the mode again. + let yamlToggleKey = $state(0) + function setYamlMode(on: boolean) { + if (on === yamlMode) return + if (paneHasUnsavedChanges()) { + yamlToggleKey++ + yamlConfirm = on ? 'enter' : 'leave' + return + } + yamlMode = on + } + + function confirmYamlSwitch() { + if (yamlConfirm === 'enter') { + discardAllChanges() + yamlMode = true + } else { + yamlMode = false + } + yamlConfirm = undefined + } + + async function reloadAfterYamlApply() { + await Promise.all([ + loadSettings(), + loadSlackOAuthConfig(), + WorkspaceService.listUserWorkspaces().then((w) => usersWorkspaceStore.set(w)), + loadCopilot($workspaceStore!) + ]) + } + // The creator of a fork gets the fork members screen even when they are not an admin of it: // their `usr` row is copied from the parent, so forking as an ordinary developer leaves them // unable to bring anyone in to collaborate. Nothing else on this page opens up — the backend @@ -676,12 +722,8 @@ slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name - if (settings.slack_command_script) { - itemKind = settings.slack_command_script.split('/')[0] as 'flow' | 'script' - } - if (settings.teams_command_script) { - itemKind = settings.teams_command_script.split('/')[0] as 'flow' | 'script' - } + const commandScript = settings.teams_command_script || settings.slack_command_script + itemKind = commandScript ? (commandScript.split('/')[0] as 'flow' | 'script') : 'flow' slackScriptPath = (settings.slack_command_script ?? '').split('/').slice(1).join('/') teamsScriptPath = (settings.teams_command_script ?? '').split('/').slice(1).join('/') slackInitialPath = slackScriptPath @@ -764,6 +806,8 @@ triggers: (settings.deploy_ui.include_type?.indexOf('trigger') ?? -1) >= 0 } } + } else { + deployUiSettings = defaultDeployUiSettings() } // Store initial deploy settings state for unsaved changes detection @@ -867,7 +911,7 @@ } } - let deployUiSettings: { + type DeployUiSettings = { include_path: string[] include_type: { scripts: boolean @@ -878,18 +922,22 @@ secrets: boolean triggers: boolean } - } = $state({ - include_path: [], - include_type: { - scripts: true, - flows: true, - apps: true, - resources: true, - variables: true, - secrets: true, - triggers: true + } + function defaultDeployUiSettings(): DeployUiSettings { + return { + include_path: [], + include_type: { + scripts: true, + flows: true, + apps: true, + resources: true, + variables: true, + secrets: true, + triggers: true + } } - }) + } + let deployUiSettings: DeployUiSettings = $state(defaultDeployUiSettings()) $effect(() => { // `canAdmin` is read as a dependency, not inside untrack: $userStore is repopulated @@ -1187,6 +1235,9 @@ // Combined function to check for unsaved changes across all tabs function getAllUnsavedChanges() { + if (showYaml) { + return yamlEditor?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } + } switch (tab) { case 'windmill_data_tables': return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } @@ -1235,6 +1286,10 @@ // Combined function to discard changes based on current tab function discardAllChanges() { + if (showYaml) { + yamlEditor?.discard() + return + } switch (tab) { case 'ai': aiSettingsComponent?.discard() @@ -1482,7 +1537,11 @@ two controls that sat beside the title are the page's actions. The panes below scroll inside themselves, so the bar keeps its edge at rest rather than only under a scroll. --> @@ -1492,17 +1551,19 @@ -
- { - const params = new URLSearchParams($page.url.searchParams) - params.set('tab', id) - goto(`?${params.toString()}`) - }} - /> -
+ {#if !showYaml} +
+ { + const params = new URLSearchParams($page.url.searchParams) + params.set('tab', id) + goto(`?${params.toString()}`) + }} + /> +
+ {/if}
@@ -1510,6 +1571,8 @@
{#if !loadedSettings} + {:else if showYaml} + {:else if tab == 'users'} {#if canAdmin} @@ -2439,7 +2502,28 @@ export async function main( tabMode={true} /> + { + yamlConfirm = undefined + }} +> + + {yamlConfirm === 'enter' + ? 'Switching to YAML mode discards the changes you made in this pane.' + : 'Leaving YAML mode discards the changes you made in the editor.'} + + + {#snippet settingsActions()} + {#if canAdmin} + {#key yamlToggleKey} + yamlMode, setYamlMode} options={{ right: 'YAML' }} size="sm" /> + {/key} + {/if} {#if $superadmin}
+ -
- - - Keep in mind that rulesets can take up to one minute to take effect - -
- - {#if isNameLocked} -
- Managed by the dev workspace pairing, which locates this rule by name. Its restrictions and - bypassers below can still be changed. -
- {:else if nameError} -
{nameError}
- {/if} -
+ +{#snippet description(text: string)} +

{text}

+{/snippet} - -
- -
- - !selectedUsers.includes(u)))} - placeholder="Select users..." - /> - {#if selectedUsers.length > 0} -
- {#each selectedUsers as user (user)} - - {user} - - - {/each} -
- {/if} -
-
+
+ + +
- -
-
- -
+
+
-
- Users must use a fork or git branch to make changes. Direct edits are not allowed. -
+ {@render description( + 'Users must use a fork or git branch to make changes. Direct edits are not allowed.' + )}
- - -
- -
Users cannot create forks of this workspace.
+
+ + {@render description('Users cannot create forks of this workspace.')}
- - -
+
-
- Only workspace admins and members of wm_deployers can deploy to this workspace. - Non-deployers can still fork, browse, and request a review. -
+ {@render description( + 'Only workspace admins and members of wm_deployers can deploy to this workspace. Non-deployers can still fork, browse, and request a review.' + )}
- - -
+
-
- Only workspace admins and bypass users can make an app publicly accessible without login - (anonymous execution mode). Apps that are already public can still be redeployed. -
+ {@render description( + 'Only workspace admins and bypassers can make an app publicly accessible without login (anonymous execution mode). Apps that are already public can still be redeployed.' + )}
- - -
+
-
- Only workspace admins and bypass users can open an app to guests (anyone who signs in, - with no workspace membership and no seat). Apps that already admit guests can still be - redeployed. -
+ {@render description( + 'Only workspace admins and bypassers can open an app to guests (anyone who signs in, with no workspace membership and no seat). Apps that already admit guests can still be redeployed.' + )}
- - -
+
-
- Only workspace admins and bypass users can mint a public link to a run (readable without - login). The read-only link for workspace members stays available to everyone. -
+ {@render description( + 'Only workspace admins and bypassers can mint a public link to a run (readable without login). The read-only link for workspace members stays available to everyone.' + )}
-
-
+
+
- -
+ {#snippet actions()} - - {#if hasUnsavedChanges && !isCreateMode} - You have unsaved changes - {/if} -
-
+ {/snippet} + diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte index 142c2ad29f..805dc85764 100644 --- a/frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte @@ -1,5 +1,5 @@ - - { - loadRules() - ruleDrawer?.closeDrawer() - }} - /> - + ruleDrawer?.closeDrawer()} + onUpdate={() => { + loadRules() + ruleDrawer?.closeDrawer() + }} + /> {#if !$enterpriseLicense} From 247202c1c543b2c219adb7a131a9ff8b5f6d0840 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 14 Oct 2026 15:49:29 +0200 Subject: [PATCH 22/49] oauth: add ms_onedrive, ms_sharepoint and ms_excel providers (#11735) * oauth: add onedrive, sharepoint and microsoft_excel providers Co-Authored-By: Claude Opus 5.5 * oauth: rename microsoft_excel provider to excel, add msword icon Co-Authored-By: Claude Opus 5.5 * oauth: rename providers to msonedrive, mssharepoint, msexcel Co-Authored-By: Claude Opus 5.5 * oauth: use underscore slugs ms_onedrive, ms_sharepoint, ms_excel, ms_word Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/oauth_connect.json | 63 +++++++++++++ .../src/lib/components/icons/ExcelIcon.svelte | 76 ++++++++++++++++ .../lib/components/icons/OnedriveIcon.svelte | 69 +++++++++++++++ .../components/icons/SharepointIcon.svelte | 88 +++++++++++++++++++ .../src/lib/components/icons/WordIcon.svelte | 65 ++++++++++++++ frontend/src/lib/components/icons/index.ts | 12 +++ 6 files changed, 373 insertions(+) create mode 100644 frontend/src/lib/components/icons/ExcelIcon.svelte create mode 100644 frontend/src/lib/components/icons/OnedriveIcon.svelte create mode 100644 frontend/src/lib/components/icons/SharepointIcon.svelte create mode 100644 frontend/src/lib/components/icons/WordIcon.svelte diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 9ed7dc67bb..d3ac7a3809 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -125,6 +125,69 @@ "prompt": "consent" } }, + "ms_onedrive": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Files.ReadWrite"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Files.Read", "label": "Read your files" }, + { "value": "Files.ReadWrite", "label": "Read, upload, edit and delete your files" }, + { "value": "Files.Read.All", "label": "Read all files you can access (shared and SharePoint drives)" }, + { "value": "Files.ReadWrite.All", "label": "Read and edit all files you can access (shared and SharePoint drives)" } + ], + "connect_config_template": { + "display_name": "Microsoft OneDrive", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Files.ReadWrite"] + } + }, + "ms_sharepoint": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Sites.ReadWrite.All"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Sites.Read.All", "label": "Read sites, lists and files" }, + { "value": "Sites.ReadWrite.All", "label": "Read and edit lists, list items and files in all sites" }, + { "value": "Sites.Manage.All", "label": "Create and manage lists and libraries" } + ], + "connect_config_template": { + "display_name": "Microsoft SharePoint", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Sites.ReadWrite.All"] + } + }, + "ms_excel": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Files.ReadWrite"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Files.ReadWrite", "label": "Read and edit workbooks in your OneDrive" }, + { "value": "Files.ReadWrite.All", "label": "Read and edit workbooks in shared and SharePoint drives" } + ], + "connect_config_template": { + "display_name": "Microsoft Excel", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Files.ReadWrite"] + } + }, "gmail": { "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", diff --git a/frontend/src/lib/components/icons/ExcelIcon.svelte b/frontend/src/lib/components/icons/ExcelIcon.svelte new file mode 100644 index 0000000000..250214c56f --- /dev/null +++ b/frontend/src/lib/components/icons/ExcelIcon.svelte @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/OnedriveIcon.svelte b/frontend/src/lib/components/icons/OnedriveIcon.svelte new file mode 100644 index 0000000000..71b5f60cf4 --- /dev/null +++ b/frontend/src/lib/components/icons/OnedriveIcon.svelte @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/SharepointIcon.svelte b/frontend/src/lib/components/icons/SharepointIcon.svelte new file mode 100644 index 0000000000..26e1a30e72 --- /dev/null +++ b/frontend/src/lib/components/icons/SharepointIcon.svelte @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/WordIcon.svelte b/frontend/src/lib/components/icons/WordIcon.svelte new file mode 100644 index 0000000000..e72371b1e6 --- /dev/null +++ b/frontend/src/lib/components/icons/WordIcon.svelte @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index 70d2450f29..8cb8ce7bb3 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -3,6 +3,10 @@ import DbIcon from './DbIcon.svelte' import DiscordIcon from './DiscordIcon.svelte' import GcalIcon from './GcalIcon.svelte' import GdriveIcon from './GdriveIcon.svelte' +import OnedriveIcon from './OnedriveIcon.svelte' +import SharepointIcon from './SharepointIcon.svelte' +import ExcelIcon from './ExcelIcon.svelte' +import WordIcon from './WordIcon.svelte' import GithubIcon from './GithubIcon.svelte' import GitlabIcon from './GitlabIcon.svelte' import GmailIcon from './GmailIcon.svelte' @@ -361,6 +365,10 @@ export const APP_TO_ICON_COMPONENT = { gcloud: GoogleCloudIcon, gcal: GcalIcon, gdrive: GdriveIcon, + ms_onedrive: OnedriveIcon, + ms_sharepoint: SharepointIcon, + ms_excel: ExcelIcon, + ms_word: WordIcon, airtable: AirtableIcon, toggl: TogglIcon, s3: S3Icon, @@ -694,6 +702,10 @@ export { DiscordIcon, GcalIcon, GdriveIcon, + OnedriveIcon, + SharepointIcon, + ExcelIcon, + WordIcon, GithubIcon, GitlabIcon, GmailIcon, From 74c5f82c6703a08a6d1a896b5e46b7007f2e3d73 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 14 Oct 2026 15:50:31 +0200 Subject: [PATCH 23/49] feat: let operators with manage rights save schedule and trigger drafts (#11646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: operators get AI sessions `prefersSessionHandoff` stops taking the role, so every AI entry point an operator can reach — Ask AI, the home composer, Open in session, the flow editor's hand-off, the raw-app template picker — opens a session for them like anyone else. The sessions page drops its refusal, and the layout stops keeping them on the docked chat. In the sidebar they now get the Workspace/AI Sessions switch and session mode, and lose the Ask AI entry that stood in for them, since the pane behind it is no longer the one they are sent to. Co-Authored-By: Claude Opus 5 (1M context) * feat: operators get run-oriented examples in the home composer Co-Authored-By: Claude Opus 5.5 (1M context) * docs: note builder_flows in the operator composer examples comment Co-Authored-By: Claude Opus 5.5 (1M context) * feat: gate session tools and editors on operator rights Co-Authored-By: Claude Opus 5.5 (1M context) * feat: keep operator AI sessions behind a browser opt-in Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep flows refused in the prompt for operators without builder rights Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: decide every frontend edit check through one editRights module Co-Authored-By: Claude Opus 5.5 (1M context) * fix: let admins who are also operators open session editors, as drafts.rs does Co-Authored-By: Claude Opus 5.5 (1M context) * feat: let operators with manage rights save schedule and trigger drafts Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep rebase_draft from operators who can only draft schedules and triggers Co-Authored-By: Claude Opus 5.5 (1M context) * fix: list operators' schedule and trigger drafts under their draft rights Co-Authored-By: Claude Opus 5.5 (1M context) * docs: say which drafts write_draft gates now that schedules and triggers have their own Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../tests/operator_manage_rights.rs | 92 ++++++++++++++++++- backend/windmill-api-schedule/src/lib.rs | 8 +- backend/windmill-api/src/drafts.rs | 35 +++---- backend/windmill-common/src/user_drafts.rs | 16 ++++ backend/windmill-common/src/workspaces.rs | 18 ++++ backend/windmill-trigger/src/handler.rs | 8 +- docs/operator-write-rights.md | 21 +++++ .../components/copilot/chat/global/core.ts | 41 ++++++++- .../copilot/chat/global/sessionAccess.test.ts | 33 +++++-- .../copilot/chat/global/sessionAccess.ts | 8 ++ .../chat/global/sessionToolset.test.ts | 10 +- .../copilot/chat/sessionCapabilities.ts | 12 ++- frontend/src/lib/editRights.test.ts | 10 +- frontend/src/lib/editRights.ts | 21 +++-- 14 files changed, 277 insertions(+), 56 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs index ae1979f2fb..cfc9441dca 100644 --- a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs +++ b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs @@ -4,10 +4,10 @@ use sqlx::{Pool, Postgres}; use windmill_common::workspaces::invalidate_operator_rights_cache; use windmill_test_utils::*; -/// Every test here must be `#[serial]`. The rights cache is process-global and keyed by workspace -/// id alone, while `sqlx::test` gives each test its own database under this same id — so two -/// running at once can answer each other's reads from one cached entry, and a withdrawal in one -/// database reads as granted in the other. +/// Every test here must be `#[serial]`, and start by dropping the cache entry. The rights cache is +/// process-global and keyed by workspace id alone, while `sqlx::test` gives each test its own +/// database under this same id — so two running at once can answer each other's reads from one +/// cached entry, and a withdrawal a test ends on reads as withdrawn in the next one's database. const WS: &str = "test-workspace"; fn operator_client() -> reqwest::Client { @@ -53,6 +53,7 @@ async fn set_settings(api: &str, body: serde_json::Value) -> anyhow::Result #[serial] async fn test_operator_manage_rights(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; + invalidate_operator_rights_cache(WS); let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let api = format!("http://localhost:{port}/api/w/{WS}"); @@ -145,6 +146,7 @@ async fn test_manage_triggers_covers_a_route_outside_the_shared_handler( db: Pool, ) -> anyhow::Result<()> { initialize_tracing().await; + invalidate_operator_rights_cache(WS); let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let api = format!("http://localhost:{port}/api/w/{WS}"); @@ -213,3 +215,85 @@ async fn test_manage_triggers_covers_a_route_outside_the_shared_handler( Ok(()) } + +/// A draft is what the AI chat writes before it deploys, so an operator holding a manage right +/// may draft that kind, and only while they hold it. Builder rights are left off throughout: they +/// open flow drafts, and would otherwise be what admits these. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_manage_rights_admit_matching_drafts(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + invalidate_operator_rights_cache(WS); + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/{WS}"); + let c = operator_client(); + + let save = async |kind: &str, path: &str| -> anyhow::Result<(u16, String)> { + let resp = c + .post(format!("{api}/drafts/update/{kind}/{path}")) + .json(&json!({ "value": { "path": path } })) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) + }; + let listed = async || -> anyhow::Result { + let resp = c.get(format!("{api}/drafts/list")).send().await?; + assert_eq!(resp.status(), 200); + Ok(resp.json().await?) + }; + let schedule_list_has_draft = async || -> anyhow::Result { + let resp = c + .get(format!("{api}/schedules/list?include_draft_only=true")) + .send() + .await?; + assert_eq!(resp.status(), 200); + let rows: Vec = resp.json().await?; + Ok(rows.iter().any(|r| r["path"] == "u/operator/sched_draft")) + }; + + let resp = c + .post(format!("{api}/drafts/update/trigger_schedule/u/operator/sched_draft")) + .json(&json!({ "value": { + "path": "u/operator/sched_draft", + "schedule": "0 0 12 * * *", + "timezone": "UTC", + "script_path": "f/system/hello", + "is_flow": false, + "args": {}, + "enabled": false + } })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert!(schedule_list_has_draft().await?); + let (status, body) = save("trigger_http", "u/operator/http_draft").await?; + assert_eq!(status, 200, "{body}"); + // The rights name schedules and triggers, nothing else. + let (status, body) = save("script", "u/operator/script_draft").await?; + assert_eq!(status, 403, "{body}"); + + let drafts = listed().await?; + assert_eq!(drafts.as_array().map(Vec::len), Some(2), "{drafts}"); + + assert_eq!( + set_settings(&api, json!({"manage_schedules": false})).await?, + 200 + ); + let (status, body) = save("trigger_schedule", "u/operator/sched_draft").await?; + assert_eq!(status, 403, "{body}"); + // The list follows the right too, as the app list follows `builder_apps`. + assert!(!schedule_list_has_draft().await?); + let (status, body) = save("trigger_http", "u/operator/http_draft").await?; + assert_eq!(status, 200, "{body}"); + + assert_eq!( + set_settings(&api, json!({"manage_triggers": false})).await?, + 200 + ); + let (status, body) = save("trigger_http", "u/operator/http_draft").await?; + assert_eq!(status, 403, "{body}"); + assert_eq!(listed().await?, json!([])); + + Ok(()) +} diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index b8ee9ded91..646052699e 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -916,7 +916,6 @@ async fn list_schedule( // Append the authed user's draft-only schedules; see scripts.rs. if lsq.include_draft_only.unwrap_or(false) - && !authed.is_operator && offset == 0 && lsq.path.is_none() && lsq.is_flow.is_none() @@ -927,6 +926,13 @@ async fn list_schedule( && lsq.summary.is_none() && lsq.broad_filter.is_none() && lsq.label.is_none() + && (!authed.is_operator + || windmill_common::workspaces::operator_can_draft( + &db, + &w_id, + Some(UserDraftItemKind::TriggerSchedule), + ) + .await?) { let draft_only_rows = fetch_draft_only_list_rows( &db, diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index e6b6db6919..01628e8289 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -24,7 +24,7 @@ use windmill_common::{ users::resolve_username_to_email, utils::{check_proper_path, require_admin, strip_json_nul}, variables::{build_crypt, encrypt}, - workspaces::{operator_builder_rights, BuilderKind}, + workspaces::operator_can_draft, }; pub fn workspaced_service() -> Router { @@ -109,10 +109,10 @@ async fn list_drafts( Path(w_id): Path, Query(query): Query, ) -> Result>> { - // Without builder rights an operator has no drafts of their own (they can't write any, see + // An operator who may draft no kind at all has no drafts of their own (see // `require_can_write_path`), so this list is always empty for them. They can still READ some // collaborators' drafts via `/drafts/get`. - if authed.is_operator && !operator_builder_rights(&db, &w_id).await?.any() { + if authed.is_operator && !operator_can_draft(&db, &w_id, None).await? { return Ok(Json(vec![])); } let all_users = query.all_users.unwrap_or(false); @@ -1252,8 +1252,7 @@ fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { } /// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are -/// rejected, except for flow drafts in a workspace that granted them builder -/// rights. Two layers: +/// rejected, except for a kind they could deploy (`operator_can_draft`). Two layers: /// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable /// `f/`) — mirror what RLS reads from the same JWT claims, and are the /// ENTIRE check for draft-only paths (no deployed row for RLS to use). @@ -1270,26 +1269,16 @@ pub(crate) async fn require_can_write_path( if authed.is_admin { return Ok(()); } - // Operators are read-only and never WRITE drafts, except of a kind the workspace granted them - // the matching builder right for: a flows-only workspace must not get raw-app drafts through - // here. Read access is deliberately asymmetric: + // Operators WRITE drafts only of a kind they could deploy: flows and full-code apps under their + // builder right, schedules and triggers under the manage rights. The kind has to be checked, or + // one right would open drafts of kinds it says nothing about. Read access is deliberately asymmetric: // `require_can_read_path` has no operator block, so an operator can still READ a draft they // can read via `/drafts/get`, mirroring their read access to deployed content. Intended. - if authed.is_operator { - let allowed = match kind { - UserDraftItemKind::Flow => Some(BuilderKind::Flows), - UserDraftItemKind::RawApp => Some(BuilderKind::Apps), - _ => None, - }; - let granted = match allowed { - Some(kind) => operator_builder_rights(db, w_id).await?.has(kind), - None => false, - }; - if !granted { - return Err(Error::PermissionDenied( - "operators cannot save drafts".to_string(), - )); - } + if authed.is_operator && !operator_can_draft(db, w_id, Some(kind)).await? { + return Err(Error::PermissionDenied(format!( + "operators cannot save {} drafts in this workspace", + kind.as_str() + ))); } // Cheap claim-based namespace checks first: they evaluate the same JWT // claims RLS reads, so the outcome matches the policies while sparing the diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index ebd0ce363f..cd7d6e0959 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -18,6 +18,7 @@ pub use windmill_types::user_drafts::DraftUserRef; use crate::db::DB; use crate::error::Result; +use crate::workspaces::ManageKind; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -160,6 +161,21 @@ impl UserDraftItemKind { } } + /// The withdrawable operator right that governs deploying this kind, which is + /// therefore also the right to draft it. Every trigger kind maps here, native ones + /// included: `gate_operator_writes` covers their routers as well. + pub fn manage_kind(&self) -> Option { + use UserDraftItemKind::*; + match self { + TriggerSchedule => Some(ManageKind::Schedules), + TriggerWebhook | TriggerDefaultEmail | TriggerEmail | TriggerHttp + | TriggerWebsocket | TriggerPostgres | TriggerKafka | TriggerNats | TriggerMqtt + | TriggerAmqp | TriggerSqs | TriggerGcp | TriggerAzure | TriggerPoll | TriggerCli + | TriggerNextcloud | TriggerGoogle | TriggerGithub => Some(ManageKind::Triggers), + Script | Flow | App | RawApp | Resource | Variable | DataPipeline => None, + } + } + /// The `draft.value` key holding the user-typed target path — where a /// deploy of this draft would land when the user staged a rename. A script /// draft round-trips its own `path`; flows and apps write a separate diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 367aa9146e..c73079bba8 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1365,6 +1365,24 @@ pub async fn operator_builder_rights(db: &DB, workspace_id: &str) -> Result, +) -> Result { + use crate::user_drafts::UserDraftItemKind; + let rights = operator_rights(db, workspace_id).await?; + Ok(match kind { + None => rights.builder.any() || rights.manage.schedules || rights.manage.triggers, + Some(UserDraftItemKind::Flow) => rights.builder.has(BuilderKind::Flows), + Some(UserDraftItemKind::RawApp) => rights.builder.has(BuilderKind::Apps), + Some(kind) => kind.manage_kind().is_some_and(|m| rights.manage.has(m)), + }) +} + /// Gate for a write only a workspace that granted the builder right covering `kind` lets operators /// perform. `action` completes "Operators cannot {action} for security reasons". pub async fn check_operator_can_build( diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index ba40ed2c48..6022e0d55f 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -695,12 +695,18 @@ async fn list_triggers( // Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind // config can deviate, so drop a row on deserialize failure rather than fail the list. if query.include_draft_only.unwrap_or(false) - && !authed.is_operator && query.page.unwrap_or(0) == 0 && query.path.is_none() && query.is_flow.is_none() && query.path_start.is_none() && query.label.is_none() + && (!authed.is_operator + || windmill_common::workspaces::operator_can_draft( + &db, + &workspace_id, + Some(T::user_draft_item_kind()), + ) + .await?) { let draft_only_rows = fetch_draft_only_list_rows( &db, diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md index 2c18ecd290..2cef9ef5df 100644 --- a/docs/operator-write-rights.md +++ b/docs/operator-write-rights.md @@ -54,6 +54,27 @@ It refuses with `PermissionDenied` (403), never `NotAuthorized` (401): the front uncaught 401 as an expired session and logs the user out, so 401 here ejects an operator from the app rather than telling them why. Both integration tests assert the status for that reason. +## Drafts + +An operator may save a draft of a kind they could deploy, and no other: a schedule draft while +they hold `manage_schedules`, a trigger draft (every trigger kind, native ones included) while +they hold `manage_triggers`, a flow or full-code app draft under `builder_flows` / `builder_apps`. +drafts.rs `require_can_write_path` asks `operator_can_draft`, which reads the same cached rights +as the router gate, and `UserDraftItemKind::manage_kind` maps each draft kind to its right. Resource and variable drafts +stay refused even though operators edit those items directly. The AI session chat writes +schedules and triggers only as drafts and then deploys them, so without this rule an operator who +may create a schedule on its page could not ask the chat for one. + +The schedule and trigger list routes append an operator's draft-only rows under the same +`operator_can_draft` check, so a saved draft shows on its list page exactly while the operator may +still save it. + +Deploying a draft goes through the ordinary create and update routes, so the router gate is still +what decides the write. The draft rule only has to agree with it: a draft the gate would refuse to +deploy should not be saved in the first place. The frontend mirrors the rule in `roleCanDraft` +(`editRights.ts`), which the chat's `write_schedule_draft` / `write_trigger_draft` capabilities +come from. + ## In the UI The shared lists (`TriggerList`, `SchedulesList`, `NativeTriggerTable`) derive a per-row `canEdit` diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 67c796f78a..c1437cb21f 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -234,6 +234,8 @@ import { WRITE_APP_DRAFT, WRITE_DRAFT, WRITE_FLOW_DRAFT, + WRITE_SCHEDULE_DRAFT, + WRITE_TRIGGER_DRAFT, type SessionAccess, type SessionTool, type SessionToolPolicy @@ -1473,12 +1475,28 @@ const buildGlobalSystemPrompt = ( const canWriteDraft = !access || access.has('write_draft') const canWriteFlowDraft = !access || access.has('write_flow_draft') const canWriteAppDraft = !access || access.has('write_app_draft') + const canDraftSchedules = !access || access.has('write_schedule_draft') + const canDraftTriggers = !access || access.has('write_trigger_draft') // A builder operator: drafts of the kinds the workspace lets them compose, and nothing else. // Blocks about drafting in general follow `canWriteAnyDraft`; blocks naming scripts or the // other kinds keep `canWriteDraft`. const buildsFlows = canWriteFlowDraft && !canWriteDraft const buildsApps = canWriteAppDraft && !canWriteDraft - const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft || canWriteAppDraft + const canWriteAnyDraft = + canWriteDraft || canWriteFlowDraft || canWriteAppDraft || canDraftSchedules || canDraftTriggers + // Both read only where `write_draft` is missing: it implies the two draft rights. + const draftedOperatorKinds = [canDraftSchedules && 'schedules', canDraftTriggers && 'triggers'] + .filter((k): k is string => !!k) + .join(' and ') + const refusedKinds = [ + !buildsFlows && 'flows', + 'scripts', + !buildsApps && 'apps', + !canDraftSchedules && 'schedules', + !canDraftTriggers && 'triggers', + 'resources', + 'variables' + ].filter((k): k is string => !!k) const refusedCode = ['scripts', !buildsFlows && 'flows', !buildsApps && 'apps'].filter( (k): k is string => !!k ) @@ -1537,7 +1555,7 @@ The current user's workspace username is "${username}".${instanceLine} ${ canWriteDraft ? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.' - : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${buildsFlows ? '' : 'flows, '}scripts, ${buildsApps ? '' : 'apps, '}schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin, who grants operators more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor. ${capitalize(listJoin(['resources', 'variables', canManageSchedules && 'schedules', canManageTriggers && 'triggers']))} they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when( + : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${refusedKinds.slice(0, -1).join(', ')} or ${refusedKinds.at(-1)} here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin, who grants operators more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor. ${capitalize(listJoin(['resources', 'variables', canManageSchedules && !canDraftSchedules && 'schedules', canManageTriggers && !canDraftTriggers && 'triggers']))} they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when( canManageSchedules, ' To make something recur, they can schedule a deployed script or flow themselves; only when none does the job is there something to ask an admin for, and it is that script.' )} When you decline something, give the reason stated here and keep to it.${when( @@ -1546,6 +1564,15 @@ ${ )}${when( buildsApps, `\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.` + )}${when( + !!draftedOperatorKinds, + `\n\nThis workspace lets them create and edit ${draftedOperatorKinds}: write them as drafts with ${[ + canDraftSchedules && 'write_schedule', + canDraftTriggers && + 'write_trigger (call get_trigger_schema first: the config fields differ per kind)' + ] + .filter(Boolean) + .join(' and ')}, then deploy them with deploy_workspace_item once the user asks.` )}` }${when( canWriteAnyDraft, @@ -2719,7 +2746,9 @@ export function getSessionContextPromptSection( !access || access.has('write_draft') || access.has('write_flow_draft') || - access.has('write_app_draft') + access.has('write_app_draft') || + access.has('write_schedule_draft') || + access.has('write_trigger_draft') const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview') // The kinds no deploy rule reaches, unless the workspace withdrew them from this user. const unruledKinds = [ @@ -4028,7 +4057,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_SCHEDULE_DRAFT, def: createToolDef( writeScheduleToolSchema, 'write_schedule', @@ -4054,7 +4083,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_TRIGGER_DRAFT, def: createToolDef( writeTriggerSchema, 'write_trigger', @@ -4388,6 +4417,8 @@ export const globalTools: SessionTool<{}>[] = [ app: WRITE_APP_DRAFT, resource: WRITE_DRAFT, variable: WRITE_DRAFT, + // Not the schedule/trigger draft rights: `rebaseDraft` has no handler for these kinds, + // so an operator holding only those rights would be offered a tool that always throws. schedule: WRITE_DRAFT, trigger: WRITE_DRAFT } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts index eca9f96bf9..41f6af2d5c 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -56,7 +56,9 @@ describe('resolveSessionAccess', () => { 'run_preview', 'write_app_draft', 'write_draft', - 'write_flow_draft' + 'write_flow_draft', + 'write_schedule_draft', + 'write_trigger_draft' ]) }) @@ -64,13 +66,24 @@ describe('resolveSessionAccess', () => { // so withholding `deploy` from an operator would be stricter than the server. it('leaves an operator the deploy-rule capability, and nothing their token refuses', async () => { const caps = await capabilitiesFor({ operator: true }) - expect([...caps].sort()).toEqual(['deploy', 'manage_schedules', 'manage_triggers']) + expect([...caps].sort()).toEqual([ + 'deploy', + 'manage_schedules', + 'manage_triggers', + 'write_schedule_draft', + 'write_trigger_draft' + ]) }) it('takes deploy from the rules alone, so a rule blocks an operator too', async () => { deployRules.mockResolvedValue({ ok: false, refusedBy: 'DisableDirectDeployment' }) const caps = await capabilitiesFor({ operator: true }) - expect([...caps].sort()).toEqual(['manage_schedules', 'manage_triggers']) + expect([...caps].sort()).toEqual([ + 'manage_schedules', + 'manage_triggers', + 'write_schedule_draft', + 'write_trigger_draft' + ]) }) // Both spellings of `authed.is_admin`, which the draft path honours and the handlers @@ -86,7 +99,9 @@ describe('resolveSessionAccess', () => { 'manage_triggers', 'write_app_draft', 'write_draft', - 'write_flow_draft' + 'write_flow_draft', + 'write_schedule_draft', + 'write_trigger_draft' ]) } ) @@ -117,7 +132,9 @@ describe('resolveSessionAccess', () => { 'manage_schedules', 'manage_triggers', 'run_flow_preview', - 'write_flow_draft' + 'write_flow_draft', + 'write_schedule_draft', + 'write_trigger_draft' ]) }) @@ -134,7 +151,9 @@ describe('resolveSessionAccess', () => { 'manage_apps', 'manage_schedules', 'manage_triggers', - 'write_app_draft' + 'write_app_draft', + 'write_schedule_draft', + 'write_trigger_draft' ]) }) @@ -147,7 +166,9 @@ describe('resolveSessionAccess', () => { operatorSettings: { manage_schedules: false } }) expect(caps.has('manage_schedules')).toBe(false) + expect(caps.has('write_schedule_draft')).toBe(false) expect(caps.has('manage_triggers')).toBe(true) + expect(caps.has('write_trigger_draft')).toBe(true) }) it('grants everything when the role cannot be resolved', async () => { diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index 6e65d833d5..0999de617b 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -15,6 +15,8 @@ const ALL_CAPABILITIES: SessionCapability[] = [ 'write_draft', 'write_flow_draft', 'write_app_draft', + 'write_schedule_draft', + 'write_trigger_draft', 'run_preview', 'run_flow_preview', 'deploy', @@ -59,6 +61,12 @@ export function capabilitiesForRole(role: { if (roleCanDraft('raw_app', user, role.operatorSettings)) { capabilities.add('write_app_draft') } + if (roleCanDraft('schedule', user, role.operatorSettings)) { + capabilities.add('write_schedule_draft') + } + if (roleCanDraft('trigger', user, role.operatorSettings)) { + capabilities.add('write_trigger_draft') + } if (authorsCode) { capabilities.add('run_preview') capabilities.add('manage_code') diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts index 6389bd6ef3..5ef973f5fd 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -76,12 +76,14 @@ const REACHABLE_PROFILES = [ [false, true].flatMap((operator) => // `checkDeployRules` bypasses on admin, so an admin has only the one outcome. (isAdmin ? [true] : [false, true]).flatMap((deployRulesPass) => - // The operator rights a workspace can set: none, the builder right, both locks. + // The operator rights a workspace can set: none, either builder right or both, either lock, both locks. [ undefined, { builder_flows: true }, { builder_apps: true }, { builder_flows: true, builder_apps: true }, + { manage_schedules: false }, + { manage_triggers: false }, { manage_schedules: false, manage_triggers: false } ].map((operatorSettings) => { const access = capabilitiesForRole({ @@ -311,9 +313,9 @@ describe('session tool policies', () => { }) }).content as string - expect(prompt({})).toContain( - 'Resources, variables, schedules, and triggers they can still edit' - ) + // Schedules and triggers are not sent to the UI: the chat drafts them itself. + expect(prompt({})).toContain('Resources and variables they can still edit') + expect(prompt({})).toContain('lets them create and edit schedules and triggers') expect(prompt({})).toContain('schedule a deployed script or flow themselves') expect(prompt({ manage_schedules: false })).not.toContain('schedule a deployed script') }) diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts index ba5f4facff..bc6f8f5167 100644 --- a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -9,14 +9,20 @@ import type { Tool } from './shared' * what the model is offered and guarantees nothing. */ export type SessionCapability = - /** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and - * triggers included, though an operator may create those directly. */ + /** Passes drafts.rs `require_can_write_path` for a draft of any kind. A tool writing only + * schedule or trigger drafts requires the narrower capability below instead, which an + * operator can hold. */ | 'write_draft' /** Passes `require_can_write_path` for a flow draft, which an operator also passes where the * workspace grants `builder_flows`. The server then refuses a draft carrying inline code. */ | 'write_flow_draft' /** The same for a raw app draft, under `builder_apps`. */ | 'write_app_draft' + /** Passes `require_can_write_path` for a schedule draft: everyone `write_draft` covers, and an + * operator unless the workspace set `manage_schedules` to false. */ + | 'write_schedule_draft' + /** The same for a trigger draft, read from `manage_triggers`. */ + | 'write_trigger_draft' /** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ | 'run_preview' /** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an @@ -55,6 +61,8 @@ export const NONE: SessionToolPolicy = [] export const WRITE_DRAFT: SessionToolPolicy = ['write_draft'] export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft'] export const WRITE_APP_DRAFT: SessionToolPolicy = ['write_app_draft'] +export const WRITE_SCHEDULE_DRAFT: SessionToolPolicy = ['write_schedule_draft'] +export const WRITE_TRIGGER_DRAFT: SessionToolPolicy = ['write_trigger_draft'] export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview'] export const DEPLOY: SessionToolPolicy = ['deploy'] diff --git a/frontend/src/lib/editRights.test.ts b/frontend/src/lib/editRights.test.ts index faa853e708..978210d530 100644 --- a/frontend/src/lib/editRights.test.ts +++ b/frontend/src/lib/editRights.test.ts @@ -37,10 +37,12 @@ describe('edit rights', () => { expect(roleCanAuthor('script', adminOperator, undefined)).toBe(false) }) - // drafts.rs refuses an operator every draft but a builder flow, rights to write directly or not. - it('refuses an operator drafts of what they may write directly', () => { - expect(roleCanAuthor('trigger', user({}), undefined)).toBe(true) - expect(roleCanDraft('trigger', user({}), undefined)).toBe(false) + // drafts.rs admits an operator's draft of a kind they could deploy, and no other. + it('lets an operator draft schedules and triggers until the right is withdrawn', () => { + expect(roleCanDraft('schedule', user({}), undefined)).toBe(true) + expect(roleCanDraft('trigger', user({}), undefined)).toBe(true) + expect(roleCanDraft('schedule', user({}), { manage_schedules: false })).toBe(false) + expect(roleCanDraft('trigger', user({}), { manage_triggers: false })).toBe(false) expect(roleCanDraft('resource', user({}), undefined)).toBe(false) }) diff --git a/frontend/src/lib/editRights.ts b/frontend/src/lib/editRights.ts index 9073a03d28..02c266280d 100644 --- a/frontend/src/lib/editRights.ts +++ b/frontend/src/lib/editRights.ts @@ -69,11 +69,11 @@ export function roleCanAuthor( } /** - * Whether the user may save drafts of `kind` — what every session editor writes. Mirrors drafts.rs - * `require_can_write_path`, which differs from the item handlers both ways: it admits an admin - * before its operator branch, and it refuses every operator draft but a flow or full-code app - * under its builder right — schedules, triggers, resources and variables included, though an operator may - * write those directly. + * Whether the user may save drafts of `kind` — what every session editor and the chat's draft + * tools write. Mirrors drafts.rs `require_can_write_path`, which differs from the item handlers + * both ways: it admits an admin before its operator branch, and an operator drafts only the kinds + * they could deploy — flows and full-code apps under their builder right, schedules and triggers + * under the manage rights — never resources, variables or groups, which they write directly. */ export function roleCanDraft( kind: EditKind, @@ -81,7 +81,16 @@ export function roleCanDraft( operatorSettings: OperatorRights ): boolean { if (user?.is_admin || user?.is_super_admin || !user?.operator) return true - return builderGrants(kind, operatorSettings) + switch (kind) { + case 'flow': + case 'raw_app': + return builderGrants(kind, operatorSettings) + case 'schedule': + case 'trigger': + return roleCanAuthor(kind, user, operatorSettings) + default: + return false + } } function builderGrants(kind: EditKind, operatorSettings: OperatorRights): boolean { From 9653318c17225b35c474722d49f38dc2ecd3b9f6 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 14 Oct 2026 15:51:12 +0200 Subject: [PATCH 24/49] feat: add a navigation picker after the breadcrumb's last segment (#11649) * feat: add a navigation picker after the breadcrumb's last segment Co-Authored-By: Claude Opus 5.5 (1M context) * fix: open the navigation picker among the current page's siblings Co-Authored-By: Claude Opus 5.5 (1M context) * style: size the navigation chevron like the workspace chevron Co-Authored-By: Claude Opus 5.5 (1M context) * style: keep one-line picker lists at one-line row height Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show the navigation chevron only once the item's path is known Co-Authored-By: Claude Opus 5.5 (1M context) * fix: highlight a draft in the navigation picker by its storage path Co-Authored-By: Claude Opus 5.5 (1M context) * fix: key the picker's current item on the header path outside editors Co-Authored-By: Claude Opus 5.5 (1M context) * feat: count navigation picker picks in feature_usage Co-Authored-By: Claude Opus 5.5 (1M context) * feat: shrink the page header in stages and let the bar fold page actions Co-Authored-By: Claude Opus 5.5 (1M context) * feat: fold the runs header through the bar and wrap its filter to a second row Co-Authored-By: Claude Opus 5.5 (1M context) * feat: truncate the run id and fold run actions into one menu on a narrow bar Co-Authored-By: Claude Opus 5.5 (1M context) * feat: keep an item's summary and drop its path on a narrow page header Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show the full summary and path read-only when they cannot be edited Co-Authored-By: Claude Opus 5.5 (1M context) * feat: keep secondary detail actions in menus and the star beside the summary Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show secondary detail actions as buttons while the bar has room for them Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to 346bd5417537b76591465e63b9d15bd081e9228c This commit updates the EE repository reference after PR #868 was merged in windmill-ee-private. Previous ee-repo-ref: 581b18cac39a4bee2671fd1aa86f91b9e702e8f5 New ee-repo-ref: 346bd5417537b76591465e63b9d15bd081e9228c Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- backend/ee-repo-ref.txt | 2 +- .../lib/components/BreadcrumbNavPicker.svelte | 122 ++++++++++++++++++ .../src/lib/components/DrillPicker.svelte | 27 +++- .../src/lib/components/NavBreadcrumb.svelte | 44 ++++--- .../src/lib/components/PageHeaderBar.svelte | 12 +- .../sessions/PreviewRouterPicker.svelte | 17 ++- .../lib/components/sessions/previewPaths.ts | 1 + .../lib/components/sessions/previewRouter.ts | 2 + .../src/lib/components/workspaceTree.test.ts | 13 +- frontend/src/lib/components/workspaceTree.ts | 19 +-- 10 files changed, 217 insertions(+), 42 deletions(-) create mode 100644 frontend/src/lib/components/BreadcrumbNavPicker.svelte diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 537353ac94..27ede3cc3c 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -57f4b973e2369f54a89817ff23dc44047ba7b9e4 +346bd5417537b76591465e63b9d15bd081e9228c diff --git a/frontend/src/lib/components/BreadcrumbNavPicker.svelte b/frontend/src/lib/components/BreadcrumbNavPicker.svelte new file mode 100644 index 0000000000..1f053c0d92 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbNavPicker.svelte @@ -0,0 +1,122 @@ + + + + + {#snippet trigger()} + + {/snippet} + {#snippet content()} + + {/snippet} + diff --git a/frontend/src/lib/components/DrillPicker.svelte b/frontend/src/lib/components/DrillPicker.svelte index 99f2c3a9b1..a92ef11ddf 100644 --- a/frontend/src/lib/components/DrillPicker.svelte +++ b/frontend/src/lib/components/DrillPicker.svelte @@ -204,6 +204,9 @@ leaves and ignores the current scope. ) ) + /** Whether any row in this list has a second line, which sets every row's height. */ + const entriesTwoLine = $derived(entryList.some((e) => e.type === 'leaf' && !!e.node.secondary)) + // Browse rows annotated with the section header rendered above them. A // header is emitted when a leaf's section differs from the previous LEAF's // section — comparing against the previous entry of any type would insert a @@ -502,7 +505,12 @@ leaves and ignores the current scope. {/if} {/snippet} -{#snippet leafRow(leaf: DrillLeaf, secondary: string | undefined, baseClass: string)} +{#snippet leafRow( + leaf: DrillLeaf, + secondary: string | undefined, + baseClass: string, + twoLineHeight: boolean +)} {@const key = leaf.key} {@const isHl = key === highlightedKey} {@const isCur = !!leaf.current} @@ -541,9 +549,12 @@ leaves and ignores the current scope. onclick={(e) => pick(leaf, e.metaKey || e.ctrlKey)} > {@render defaultLeafIcon(leaf)} - -
+ +
{#if leaf.secondary}
{leaf.label}
@@ -657,6 +668,7 @@ leaves and ignores the current scope.
No matches
{:else} {#each searchResultsByGroup as { key, label, items } (key)} + {@const twoLineHeight = items.some((r) => !!r.leaf.secondary)} {#if label}
{#each items as r (r.leaf.key)} -
  • {@render leafRow(r.leaf, r.leaf.secondary ?? r.leaf.label, 'py-1.5')}
  • +
  • + {@render leafRow(r.leaf, r.leaf.secondary ?? r.leaf.label, 'py-1.5', twoLineHeight)} +
  • {/each} {/each} @@ -692,7 +706,8 @@ leaves and ignores the current scope. {@render leafRow( entry.node, leafSecondary?.(entry.node, scope) ?? entry.node.secondary, - 'py-1.5' + 'py-1.5', + entriesTwoLine )} {:else} {/if} + {#if itemIsLast}{@render navPicker(measure)}{/if} {@render afterNameSlot(measure)} {#if pathCopied && !measure} {/if} {:else if section?.kind} - {#if measure} - - {@render sectionKindIcon()}{KIND_LABEL_LOWER[section.kind]} - - {:else} - {}} - /> - {/if} + + {@render sectionKindIcon()} + {KIND_LABEL_LOWER[section.kind]} + + {@render navPicker(measure)} {:else if section} {#if section.parent} @@ -507,6 +515,7 @@ not this component) — so the lead can run the whole line. >{/if} {/if} + {@render navPicker(measure)} {@render afterNameSlot(measure)} {:else if routePage} {@const RouteIcon = routePage.icon} @@ -519,6 +528,7 @@ not this component) — so the lead can run the whole line. {#if RouteIcon}{/if} {routePage.label} + {@render navPicker(measure)} {@render afterNameSlot(measure)} {/if} {/snippet} diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 8c3dd509d5..1ad2602d8e 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -7,11 +7,12 @@ section name — and the route's own buttons at the far end. The row's height matches the sidebar's own header row, so the two read as one band. --> -
    - +
    { + el.addEventListener('keydown', onSearchKeydown) + return () => el.removeEventListener('keydown', onSearchKeydown) + }} + > +
    + + +
    +
    + {/if} + -
    - {#each familyWorkspaces as { workspace, depth, isForked, parentName, separatorBefore } (workspace.id)} - {@const isActive = $workspaceStore === workspace.id} - {@const forkAccent = isForked ? forkAccentStyle(workspace.color) : undefined} - {#if separatorBefore} - +
    + {#if searching && familyWorkspaces.length === 0} +
    No workspaces match your search
    {/if} - + {#if separatorBefore && !searching} + + {/if} + - {@const isSelected = - isActive || - (!strictWorkspaceSelect && - depth === 0 && - lineageRoot?.id === workspace.id && - !expandedFamilies.has(workspace.id))} - {@const expandable = - !strictWorkspaceSelect && depth === 0 && familiesWithForks.has(workspace.id)} - - -
    - onWorkspaceItemClick(e, workspace)} - {item} > -
    -
    - -
    -
    -
    - {workspace.name}{workspace.disabled ? ' (user disabled)' : ''} + onWorkspaceItemClick(e, workspace)} + {item} + > +
    +
    + +
    +
    +
    + {#if markedNames.get(workspace.id)} + + {@html markedNames.get(workspace.id)} + {:else} + {workspace.name} + {/if}{workspace.disabled ? ' (user disabled)' : ''} +
    + {#if workspace.is_dev_workspace} + + {/if}
    - {#if workspace.is_dev_workspace} - + {#if ambiguousNames.has(workspace.name) && !markedNames.has(workspace.id)} +
    + {workspace.id} +
    {/if}
    - {#if ambiguousNames.has(workspace.name)} -
    - {workspace.id} -
    - {/if}
    + {#if isSelected} + + {/if}
    - {#if isSelected} - - {/if} -
    - - -
    - -
    - {#if expandable} - {@const expanded = expandedFamilies.has(workspace.id)} - - {:else} - + + {:else} + - + + {/if} +
    + {/each} +
    + {#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect} +
    + {#if isCloudHosted() || $superadmin} +
    + + + Workspace + +
    + {/if} + {#if canForkHere} +
    + (globalForkModal.val = { opened: true })} + {item} + > + + Workspace fork + +
    {/if}
    - {/each} + {/if} + {#if canManageWorkspace && !strictWorkspaceSelect} +
    + + + {(activeWorkspace && ambiguousNames.has(activeWorkspace.name) + ? activeWorkspace.id + : activeWorkspace?.name) ?? $workspaceStore} settings + +
    + {/if} + {#if !strictWorkspaceSelect} +
    + clearWorkspaceFromStorage()} + class={actionClass('all')} + {item} + > + All workspaces + +
    + {/if}
    - {#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect} + {#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect}
    - {#if isCloudHosted() || $superadmin} - - - Workspace - + {#if $workspaceStore != 'demo'} + + {$workspaceUsageStore ?? '—'}/1000 free workspace execs + + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + + +
    +
    +
    {/if} - {#if canForkHere} - (globalForkModal.val = { opened: true })} {item}> - - Workspace fork - + {#if $userStore?.is_admin} +
    + { + goto('/workspace_settings?tab=premium') + }} + {item} + > + Upgrade + +
    {/if}
    {/if} - {#if canManageWorkspace && !strictWorkspaceSelect} -
    - - - {(activeWorkspace && ambiguousNames.has(activeWorkspace.name) - ? activeWorkspace.id - : activeWorkspace?.name) ?? $workspaceStore} settings - -
    - {/if} - {#if !strictWorkspaceSelect} -
    - clearWorkspaceFromStorage()} - class={itemClass} - {item} - > - All workspaces - -
    - {/if}
    -{#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect} -
    - {#if $workspaceStore != 'demo'} - - {$workspaceUsageStore ?? '—'}/1000 free workspace execs - - {#snippet text()} - {EXECUTIONS_HINT} - {/snippet} - - -
    -
    -
    - {/if} - {#if $userStore?.is_admin} - { - goto('/workspace_settings?tab=premium') - }} - {item} - > - Upgrade - - {/if} -
    -{/if} {#if $enterpriseLicense && !strictWorkspaceSelect} {/if} From 08319a004c289f93398b9fdc790b2ed412a81fa4 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 14 Oct 2026 23:19:29 +0200 Subject: [PATCH 30/49] feat: allow early return on steps inside branches (#11733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: allow early return on steps inside a branch to one The "Early return for sync webhooks" flow setting could only point at a top-level step. It can now also point at a step inside a branch to one, in any branch including the default one, and through nested branches to one. When the branch is taken, the sync call returns that step's result as soon as it completes while the rest of the flow keeps running. When it is not taken, the call returns the flow's final result. The sync wait loops already resolved nested steps and fell back to the final result, so they are unchanged. Worker: check_if_early_return_or_last_in_early_return_parent now accepts a job whose step id matches early_return at any depth, as long as every job between it and the root is a branch to one. A loop iteration or a subflow reusing the same id does not take the stream. The existing case, the last step of a branch to one that is itself the early-return step, is unchanged. Frontend: the native c.id === flowStore.val.value.early_return)} +
    + + placeholder="Node's id" + size="sm" + /> + {#if !selected} + + Step {flowStore.val.value.early_return} is not one of the steps above. Pick another + step. + + {:else if !selected.deterministic} + + This step only runs if its branch is taken. Otherwise, sync calls return the + flow's final result. + + {/if}
    {/if}
    diff --git a/frontend/src/lib/components/flows/earlyReturnCandidates.test.ts b/frontend/src/lib/components/flows/earlyReturnCandidates.test.ts new file mode 100644 index 0000000000..598d6fcd9b --- /dev/null +++ b/frontend/src/lib/components/flows/earlyReturnCandidates.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import type { FlowModule } from '$lib/gen' +import { earlyReturnCandidates } from './earlyReturnCandidates' + +function identity(id: string): FlowModule { + return { id, value: { type: 'identity' } } +} + +describe('earlyReturnCandidates', () => { + it('offers every step reached through branches, but not the steps inside loops', () => { + const modules: FlowModule[] = [ + identity('a'), + { + id: 'b', + value: { + type: 'branchone', + default: [identity('c')], + branches: [ + { + expr: 'true', + modules: [ + identity('d'), + { + id: 'e', + value: { + type: 'branchone', + default: [], + branches: [{ expr: 'true', modules: [identity('f')] }] + } + }, + { + id: 'g', + value: { type: 'branchall', branches: [{ modules: [identity('h')] }] } + }, + { + id: 'i', + value: { + type: 'forloopflow', + iterator: { type: 'javascript', expr: '[]' }, + skip_failures: false, + modules: [identity('j')] + } + } + ] + } + ] + } + }, + { id: 'k', value: { type: 'branchall', branches: [{ modules: [identity('l')] }] } }, + { + id: 'm', + value: { + type: 'forloopflow', + iterator: { type: 'javascript', expr: '[]' }, + skip_failures: false, + modules: [identity('n')] + } + } + ] + + expect(earlyReturnCandidates(modules)).toEqual([ + { id: 'a', deterministic: true }, + { id: 'b', deterministic: true }, + { id: 'c', deterministic: false }, + { id: 'd', deterministic: false }, + { id: 'e', deterministic: false }, + { id: 'f', deterministic: false }, + { id: 'g', deterministic: false }, + { id: 'h', deterministic: false }, + { id: 'i', deterministic: false }, + { id: 'k', deterministic: true }, + { id: 'l', deterministic: true }, + { id: 'm', deterministic: true } + ]) + }) +}) diff --git a/frontend/src/lib/components/flows/earlyReturnCandidates.ts b/frontend/src/lib/components/flows/earlyReturnCandidates.ts new file mode 100644 index 0000000000..0d838c9d2a --- /dev/null +++ b/frontend/src/lib/components/flows/earlyReturnCandidates.ts @@ -0,0 +1,28 @@ +import type { FlowModule } from '$lib/gen' + +export type EarlyReturnCandidate = { + id: string + // False for a step inside a branch to one: it only runs when its branch is taken. + deterministic: boolean +} + +// A loop's inner steps run once per iteration, so only the loop itself is offered. +export function earlyReturnCandidates(modules: FlowModule[]): EarlyReturnCandidate[] { + return withinBranches(modules, true) +} + +function withinBranches(modules: FlowModule[], deterministic: boolean): EarlyReturnCandidate[] { + return modules.flatMap((module) => { + const value = module.value + const nested = + value.type === 'branchone' + ? withinBranches([value.default, ...value.branches.map((b) => b.modules)].flat(), false) + : value.type === 'branchall' + ? withinBranches( + value.branches.flatMap((b) => b.modules), + deterministic + ) + : [] + return [{ id: module.id, deterministic }, ...nested] + }) +} diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index ac5c9aa55a..1120bb514f 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -112,7 +112,10 @@ components: description: Execution priority (higher numbers run first) early_return: type: string - description: JavaScript expression to return early from the flow + description: >- + Id of the step whose result a sync webhook returns as soon as that step completes, while + the rest of the flow keeps running. When the step does not run, or fails while the flow + has a failure module, the flow's final result is returned instead. chat_input_enabled: type: boolean description: Whether this flow accepts chat-style input diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index d528f36ac5..b6ba8de664 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -610,4 +610,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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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 +{"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":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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 7ea9e6bfeb..69112aed5b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3700,7 +3700,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: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n\`flow_input.model\` to choose the model per run.\\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside \`provider\`.\\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)\\nand, optionally, the \`model\` and \`reasoning_effort\` overrides of what the agent saved.\\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, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, 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"]}}`; +{"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":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"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: the n-th retry waits multiplier \\u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n\`flow_input.model\` to choose the model per run.\\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see \`agent\`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside \`provider\`.\\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)\\nand, optionally, the \`model\` and \`reasoning_effort\` overrides of what the agent saved.\\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, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, 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 69f30bcfb5..906f416184 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -698,4 +698,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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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"]}} +{"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":"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."},"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: the n-th retry waits multiplier \u00d7 seconds^n","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Base of the exponential (seconds); the n-th retry waits multiplier \u00d7 seconds^n, where n counts constant retries too"},"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"},"model":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the model\nsaved on the agent for this step's runs, on the agent's own provider resource. An\nempty value keeps the saved model. Set it to an expression such as\n`flow_input.model` to choose the model per run.\n"},"reasoning_effort":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\ndefault. Leave the input out to keep the saved effort, which holds only while the\nstep runs the saved model. A step that is not linked sets both inside `provider`.\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)\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\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, Cloudflare's\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\nstate with calibrated probabilities, 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"]}} From 5954a510af3ba9694dc004f474a88282cf3a9282 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 14 Oct 2026 23:24:35 +0200 Subject: [PATCH 31/49] chore(main): release 1.835.0 (#11771) * chore(main): release 1.835.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++ backend/Cargo.lock | 158 +++++++++--------- 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, 140 insertions(+), 126 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 399192abab..a533c73dbf 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.834.0" + ".": "1.835.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cd27df60..57ec310ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.835.0](https://github.com/windmill-labs/windmill/compare/v1.834.0...v1.835.0) (2026-10-14) + + +### Features + +* allow early return on steps inside branches ([#11733](https://github.com/windmill-labs/windmill/issues/11733)) ([08319a0](https://github.com/windmill-labs/windmill/commit/08319a004c289f93398b9fdc790b2ed412a81fa4)) +* run global ai eval cases under builder operator profiles ([#11768](https://github.com/windmill-labs/windmill/issues/11768)) ([d788f16](https://github.com/windmill-labs/windmill/commit/d788f16c4028985afd2b82f35d625b0a12c04653)) +* search the workspace picker dropdown past 8 workspaces ([#11761](https://github.com/windmill-labs/windmill/issues/11761)) ([c1a561f](https://github.com/windmill-labs/windmill/commit/c1a561f5ec091de0ae2de12ee811ca628c58c9f5)) + + +### Bug Fixes + +* show native triggers in the operator sidebar ([#11769](https://github.com/windmill-labs/windmill/issues/11769)) ([717162e](https://github.com/windmill-labs/windmill/commit/717162eab44757182a4a67316c288e188f811599)) + ## [1.834.0](https://github.com/windmill-labs/windmill/compare/v1.833.0...v1.834.0) (2026-10-14) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 17c6b9e353..b2139d6d41 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15486,7 +15486,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-nats", @@ -15576,7 +15576,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.834.0" +version = "1.835.0" dependencies = [ "async-stream", "async-trait", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "argon2", @@ -15767,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -15790,7 +15790,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -15807,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "axum", @@ -15833,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.834.0" +version = "1.835.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15843,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -15860,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "base64 0.22.1", @@ -15882,7 +15882,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "axum", @@ -15905,7 +15905,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -15921,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -15943,7 +15943,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -15979,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-nats", @@ -16015,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "axum", @@ -16041,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -16069,7 +16069,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "axum", @@ -16091,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -16111,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "axum", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "axum", @@ -16182,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.834.0" +version = "1.835.0" dependencies = [ "lazy_static", "serde", @@ -16194,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.834.0" +version = "1.835.0" dependencies = [ "argon2", "axum", @@ -16218,7 +16218,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -16232,7 +16232,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.834.0" +version = "1.835.0" dependencies = [ "axum", "chrono", @@ -16271,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.834.0" +version = "1.835.0" dependencies = [ "chrono", "lazy_static", @@ -16286,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "axum", @@ -16305,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.834.0" +version = "1.835.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16421,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.834.0" +version = "1.835.0" dependencies = [ "chrono", "futures", @@ -16441,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.834.0" +version = "1.835.0" dependencies = [ "regex", "serde", @@ -16458,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16485,7 +16485,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "futures", @@ -16502,7 +16502,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.834.0" +version = "1.835.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16518,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -16540,7 +16540,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -16571,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "arc-swap", @@ -16596,7 +16596,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-stream", @@ -16632,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "futures", @@ -16651,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.834.0" +version = "1.835.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16660,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -16672,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde_json", @@ -16684,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "gosyn", @@ -16696,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -16708,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde_json", @@ -16720,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "nu-parser", @@ -16731,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16742,7 +16742,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16754,7 +16754,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16765,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-recursion", @@ -16788,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde_json", @@ -16800,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -16814,7 +16814,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16831,7 +16831,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -16844,7 +16844,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde", @@ -16856,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -16874,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16890,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16907,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -16921,7 +16921,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-recursion", @@ -16960,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "const_format", @@ -16998,7 +16998,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.834.0" +version = "1.835.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17009,7 +17009,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-recursion", @@ -17044,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17068,7 +17068,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17101,7 +17101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17128,7 +17128,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17161,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17181,7 +17181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17215,7 +17215,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17252,7 +17252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17275,7 +17275,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17299,7 +17299,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-nats", @@ -17323,7 +17323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17358,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17386,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-trait", @@ -17411,7 +17411,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17430,7 +17430,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-once-cell", @@ -17549,7 +17549,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.834.0" +version = "1.835.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 36ebcfdc01..24955a3744 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.834.0" +version = "1.835.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.834.0" +version = "1.835.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 dfa2dd4e44..cdd8ab75f5 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.834.0" +version = "1.835.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.834.0" +version = "1.835.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.834.0" +version = "1.835.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.834.0" +version = "1.835.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 5cd932915f..aa6c5cc297 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.834.0" +version = "1.835.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 72b12c05cf..a7dc4212ba 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.834.0 + version: 1.835.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 5604d40b93..1c810190d3 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.834.0"; +export const VERSION = "v1.835.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 d1a3a70795..99b3647dc4 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.834.0", + "version": "1.835.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.834.0", + "version": "1.835.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 76f7c37d6a..e590d8656a 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.834.0", + "version": "1.835.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 3256719d72..77e085584f 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.834.0"; +export const VERSION = "1.835.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a44302a1ef..a448bfbb25 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.834.0", + "version": "1.835.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.834.0", + "version": "1.835.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index e6c7adb17e..9983885013 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.834.0", + "version": "1.835.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 c888a903e9..78e1dc12eb 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.834.0" +wmill = ">=1.835.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 1120bb514f..ddca38afc8 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.834.0 + version: 1.835.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 0625d781ed..ad1592c244 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.834.0' + ModuleVersion = '1.835.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index ece975d570..f01450a8b4 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.834.0" +version = "1.835.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 ca981a4fb5..52ba2d7886 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.834.0", + "version": "1.835.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 651d570d0d..dcaca9fda0 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.834.0", + "version": "1.835.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 9c44d04c0b..d9020f0403 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.834.0 +1.835.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index e50cb51035..f724904b5b 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.834.0", + "version": "1.835.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.834.0", + "version": "1.835.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 4812d62011..424d7458d1 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.834.0", + "version": "1.835.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 4efd9177cea08b858e66e8e1a8973c674b9d77d1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 14 Oct 2026 23:47:46 +0200 Subject: [PATCH 32/49] iterate workspace picker nav items without spreading a NodeList (#11772) Co-authored-by: Claude Opus 5.5 --- frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index 03a457b630..c70fef70a4 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -271,7 +271,7 @@ } function moveHighlight(step: 1 | -1) { - const order = [...(rootEl?.querySelectorAll('[data-nav-id]') ?? [])].map( + const order = Array.from(rootEl?.querySelectorAll('[data-nav-id]') ?? []).map( (el) => el.getAttribute('data-nav-id')! ) if (order.length === 0) return From 4f712318c3b52366b03bf7f941ecae4730fbf967 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 15 Oct 2026 12:13:27 +0200 Subject: [PATCH 33/49] feat: run previews as the runnable's on_behalf_of identity (#11778) * feat: run script and flow previews on behalf of another user Co-Authored-By: Claude Opus 5.5 * fix: refuse operators and detect servers that drop preview on_behalf_of Co-Authored-By: Claude Opus 5.5 * feat: editor previews run as the configured on_behalf_of identity Co-Authored-By: Claude Opus 5.5 * fix: probe before submitting, keep step and viewer data identity in previews Co-Authored-By: Claude Opus 5.5 * fix: never let a job token request viewer data access on a preview Co-Authored-By: Claude Opus 5.5 * feat: open impersonated preview logs with who they run as and who asked Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../tests/preview_on_behalf_of.rs | 135 +++++++++++++ backend/windmill-api/openapi.yaml | 20 ++ backend/windmill-api/src/apps.rs | 47 +++-- backend/windmill-api/src/jobs.rs | 184 ++++++++++++++++-- cli/src/commands/flow/flow.ts | 19 +- cli/src/commands/script/script.ts | 34 ++++ cli/src/guidance/skills.gen.ts | 2 + .../FlowLoopIterationPreview.svelte | 14 +- .../lib/components/FlowPreviewContent.svelte | 8 +- frontend/src/lib/components/JobLoader.svelte | 13 +- frontend/src/lib/components/ModuleTest.svelte | 35 +++- .../src/lib/components/ScriptBuilder.svelte | 6 + .../src/lib/components/ScriptEditor.svelte | 6 +- .../helpers/RunnableComponent.svelte | 9 +- .../src/lib/components/flows/utils.svelte.ts | 6 +- .../raw_apps/RawAppBackgroundRunner.svelte | 7 +- .../components/raw_apps/RawAppEditor.svelte | 4 + .../RawAppInlineScriptRunnable.svelte | 63 +++++- .../raw_apps/RawAppInlineScriptsPanel.svelte | 3 + frontend/src/lib/scripts.ts | 6 +- frontend/src/lib/utils/previewOnBehalfOf.ts | 33 ++++ .../auto-generated/cli/cli-commands.md | 2 + system_prompts/auto-generated/prompts.ts | 2 + .../skills/cli-commands/SKILL.md | 2 + 24 files changed, 610 insertions(+), 50 deletions(-) create mode 100644 backend/windmill-api-integration-tests/tests/preview_on_behalf_of.rs create mode 100644 frontend/src/lib/utils/previewOnBehalfOf.ts diff --git a/backend/windmill-api-integration-tests/tests/preview_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/preview_on_behalf_of.rs new file mode 100644 index 0000000000..a1bb7876c6 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/preview_on_behalf_of.rs @@ -0,0 +1,135 @@ +//! A preview's `on_behalf_of` runs request-supplied code as another account, so it must +//! change the job's identity for admins and be refused for everyone else. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::auth::create_jwt_token; +use windmill_common::db::Authed; +use windmill_test_utils::*; + +/// `route` is under `/api/w/test-workspace/`. +async fn post(port: u16, token: &str, route: &str, body: serde_json::Value) -> (u16, String) { + let resp = reqwest::Client::new() + .post(format!("http://localhost:{port}/api/w/test-workspace/{route}")) + .header("Authorization", format!("Bearer {token}")) + .json(&body) + .send() + .await + .unwrap(); + (resp.status().as_u16(), resp.text().await.unwrap()) +} + +async fn identity(db: &Pool, id: &str) -> (String, String) { + sqlx::query_as("SELECT permissioned_as, permissioned_as_email FROM v2_job WHERE id = $1") + .bind(Uuid::parse_str(id).unwrap()) + .fetch_one(db) + .await + .unwrap() +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_preview_on_behalf_of(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let script = |obo: &str| { + json!({ + "content": "export function main() { return 1; }", + "language": "deno", + "args": {}, + "on_behalf_of": obo, + }) + }; + let flow = |obo: &str| json!({ "value": {"modules": []}, "args": {}, "on_behalf_of": obo }); + // The app editor's preview of an inline runnable. + let app = |obo: &str| { + json!({ + "args": {}, + "component": "c", + "raw_code": { "language": "deno", "content": "export function main() { return 1; }" }, + "force_viewer_static_fields": {}, + "on_behalf_of": obo, + }) + }; + let app_route = "apps_u/execute_component/u/test-user/app"; + + for (route, body) in [ + ("jobs/run/preview", script("test2@windmill.dev")), + ("jobs/run/preview_flow", flow("u/test-user-2")), + (app_route, app("u/test-user-2")), + ] { + let (status, id) = post(port, "SECRET_TOKEN", route, body).await; + assert!((200..300).contains(&status), "{route}: {status} {id}"); + assert_eq!( + identity(&db, &id).await, + ( + "u/test-user-2".to_string(), + "test2@windmill.dev".to_string() + ), + "{route}" + ); + let logs: String = sqlx::query_scalar("SELECT logs FROM job_logs WHERE job_id = $1") + .bind(Uuid::parse_str(&id).unwrap()) + .fetch_one(&db) + .await?; + assert!( + logs.starts_with("Preview run on behalf of u/test-user-2 (test2@windmill.dev), requested by test@windmill.dev"), + "{route}: {logs}" + ); + } + + for (route, body) in [ + ("jobs/run/preview", script("test@windmill.dev")), + ("jobs/run/preview_flow", flow("u/test-user")), + (app_route, app("u/test-user")), + ] { + let (status, body) = post(port, "SECRET_TOKEN_2", route, body).await; + assert_eq!(status, 403, "{route}: {body}"); + } + + let (status, id) = post(port, "SECRET_TOKEN_2", "jobs/run/preview", script("u/test-user-2")).await; + assert_eq!(status, 201, "{id}"); + assert_eq!(identity(&db, &id).await.0, "u/test-user-2"); + + // Reading data as the end user is the direct caller's own choice. A job token's end user is + // whoever ran that job, so a job's code must not be able to ask for it. + set_jwt_secret().await; + let job_token = create_jwt_token( + Authed { + email: "test@windmill.dev".to_string(), + username: "test-user".to_string(), + is_admin: true, + is_operator: false, + groups: vec![], + folders: vec![], + scopes: None, + token_prefix: None, + }, + "test-workspace", + 3600, + Some(Uuid::new_v4()), + Some("ephemeral-script".to_string()), + None, + None, + ) + .await?; + let viewer_data = json!({ + "content": "export function main() { return 1; }", + "language": "deno", + "args": {}, + "viewer_data_access": true, + }); + for (token, expected) in [("SECRET_TOKEN", true), (job_token.as_str(), false)] { + let (status, id) = post(port, token, "jobs/run/preview", viewer_data.clone()).await; + assert_eq!(status, 201, "{id}"); + let stamped: bool = + sqlx::query_scalar("SELECT viewer_data_access FROM job_perms WHERE job_id = $1") + .bind(Uuid::parse_str(&id).unwrap()) + .fetch_one(&db) + .await?; + assert_eq!(stamped, expected, "job token: {}", !expected); + } + + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index a7dc4212ba..0b0fff1ddf 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -15628,6 +15628,14 @@ paths: description: "Map of relative-import script path -> temp storage hash. Only honored for inline-script (raw_code) execution so app dev resolves those imports from not-yet-deployed local content." additionalProperties: type: string + on_behalf_of: + type: string + nullable: true + description: "Preview mode (force_viewer_static_fields set) only: run as this identity (an email, `u/` or `g/`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers." + viewer_data_access: + type: boolean + nullable: true + description: "Preview mode only: the inline runnable's viewer_data_access policy setting (run mode reads it from the deployed policy)." required: - args - component @@ -32405,6 +32413,14 @@ components: description: "Map of relative-import script path -> temp storage hash so the preview job resolves those imports from not-yet-deployed local content instead of the deployed script" additionalProperties: type: string + on_behalf_of: + type: string + nullable: true + description: "Run the preview as this identity (an email, `u/` or `g/`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers." + viewer_data_access: + type: boolean + nullable: true + description: "Data table and object storage access as the caller rather than as the job's identity, as a full-code app's inline runnable flagged viewer_data_access does for its viewer." required: - args @@ -36749,6 +36765,10 @@ components: description: "Map of relative-import script path -> temp storage hash, propagated to each flow step so inline-script relative imports resolve from not-yet-deployed local content instead of the deployed script" additionalProperties: type: string + on_behalf_of: + type: string + nullable: true + description: "Run the preview as this identity (an email, `u/` or `g/`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers." required: - value diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 4b67f865f9..20f08e57ad 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4289,6 +4289,12 @@ pub struct ExecuteApp { /// inline-script (raw_code, preview) execution so `wmill app dev` resolves /// those imports from not-yet-deployed local content instead of deployed. pub temp_script_refs: Option>, + /// Preview mode only: run as this identity instead of the caller, as the deployed app + /// would in publisher mode. See `jobs::preview_on_behalf_of`. + pub on_behalf_of: Option, + /// Preview mode only: the inline runnable's `viewer_data_access` policy setting, which run + /// mode reads from the deployed policy instead. + pub viewer_data_access: Option, } fn digest(code: &str) -> String { @@ -4602,6 +4608,7 @@ async fn execute_component( force_viewer_allow_user_resources, force_viewer_sensitive_inputs, force_viewer_delete_after_secs, + viewer_data_access, .. } => ( &Policy { stated_execution_mode: Some(ExecutionMode::Viewer), ..Default::default() }, @@ -4612,8 +4619,9 @@ async fn execute_component( delete_after_secs: force_viewer_delete_after_secs, sensitive_inputs: force_viewer_sensitive_inputs.unwrap_or_default(), tag: None, - // A preview runs as the caller, whose roles these are either way. - viewer_data_access: false, + // Never for a job token: see `run_preview_script`. + viewer_data_access: opt_authed.as_ref().is_some_and(|a| a.job_id.is_none()) + && viewer_data_access.unwrap_or(false), }, None, ), @@ -4802,8 +4810,19 @@ async fn execute_component( } } - let (username, permissioned_as, email) = + let (username, mut permissioned_as, mut email) = get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?; + let preview_obo = match opt_authed.as_ref().filter(|_| is_preview) { + Some(authed) => { + crate::jobs::preview_on_behalf_of(authed, payload.on_behalf_of.as_deref(), &w_id, &db) + .await? + } + None => None, + }; + if let Some(obo) = preview_obo.as_ref() { + permissioned_as = obo.permissioned_as.clone(); + email = obo.email.clone(); + } let resolved_delete_secs = resolve_delete_after_secs(None, policy_triggerables.delete_after_secs); @@ -4914,16 +4933,16 @@ async fn execute_component( .await?; } } - // Identity is already resolved to the requesting user in preview mode (the - // policy is forced to `ExecutionMode::Viewer`, so the job runs as the - // caller). The enqueue stays root-isolated as before — switching the insert - // to user-RLS is not what contains the bypass (the auth guards above are) - // and would add unnecessary breakage risk to the legitimate editor flow. + // In preview mode the identity is the caller (the policy is forced to + // `ExecutionMode::Viewer`), or the gated `on_behalf_of` resolved above. The + // enqueue stays root-isolated — switching the insert to user-RLS is not what + // contains the bypass (the auth guards above are) and would add unnecessary + // breakage risk to the legitimate editor flow. let tx = PushIsolationLevel::IsolatedRoot(db.clone()); // An app component runs on-behalf of the APP identity (resolved above), never // the referenced runnable's own `on_behalf_of` — else a Viewer-mode app could - // execute as that identity and a preview would run as it, not the caller. + // execute as that identity and a preview would run as it, not as the editor chose. // (Direct `/jobs/run` still honors a runnable's `on_behalf_of`.) let (email, permissioned_as) = (email.as_str(), permissioned_as); @@ -4932,8 +4951,8 @@ async fn execute_component( // Stamp app-origination (trigger_kind='app' + trigger=), the signal // the deployed-app S3 provenance gate trusts (unforgeable via `/jobs/run`). - // Deployed runs only: a preview runs as the caller and is read back as the caller - // (viewer-scoped), so it must never be app-provenanced (else it could forge one). + // Deployed runs only: a preview runs request-supplied code and is read back as the + // caller (viewer-scoped), so it must never be app-provenanced (else it could forge one). let app_trigger = (!is_preview).then(|| TriggerMetadata::new(Some(path.to_string()), JobTriggerKind::App)); @@ -4941,6 +4960,7 @@ async fn execute_component( Some(authed) => windmill_api_auth::caller_scope_ceiling(&db, authed).await?, None => None, }; + let preview_obo_author = preview_obo.as_ref().and(opt_authed.clone()); let (uuid, mut tx) = push( &db, tx, @@ -4980,6 +5000,11 @@ async fn execute_component( ) .await?; + if let (Some(obo), Some(authed)) = (preview_obo.as_ref(), preview_obo_author.as_ref()) { + crate::jobs::audit_preview_on_behalf_of(&mut tx, authed, &w_id, uuid, Some(path), obo) + .await?; + } + if policy_triggerables.viewer_data_access && is_inline { sqlx::query!( "UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1", diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 1a7a073732..b939ba017a 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -115,7 +115,9 @@ use windmill_common::{ add_virtual_items_if_necessary, resolve_maybe_value, ApprovalSkin, FlowModule, FlowValue, Suspend, }, - jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode}, + jobs::{ + script_path_to_payload, CompletedJob, JobKind, JobPayload, OnBehalfOf, QueuedJob, RawCode, + }, oauth2::HmacSha256, query_builders, scripts::{ScriptHash, ScriptLang}, @@ -6736,6 +6738,12 @@ struct Preview { /// preview job resolves those imports from not-yet-deployed local content /// (uploaded to raw_script_temp) instead of the deployed script. temp_script_refs: Option>, + /// Run the preview as this identity (an email or a `u/`/`g/` principal) instead of the + /// caller. See `preview_on_behalf_of`. + on_behalf_of: Option, + /// Data table and object storage access as the caller rather than as the job's identity, + /// as a full-code app's inline runnable flagged `viewer_data_access` does for its viewer. + viewer_data_access: Option, } #[cfg(feature = "run_inline")] @@ -6768,6 +6776,8 @@ struct PreviewFlow { /// each flow step so inline-script relative imports resolve from /// not-yet-deployed local content instead of the deployed script. temp_script_refs: Option>, + /// See `Preview::on_behalf_of`. + on_behalf_of: Option, } #[derive(Debug, Deserialize)] @@ -8930,6 +8940,112 @@ fn operator_preview_refusal(job_id: Option) -> error::Error { error::Error::PermissionDenied(reason.to_string()) } +/// The identity a preview asks to run as, or `None` when it runs as the caller. +/// +/// A preview is request-supplied code, so running it as someone else is impersonation, not +/// "running their runnable": only those who may set a runnable's `on_behalf_of` on deploy +/// (admins and `wm_deployers`) may ask for it. Naming oneself is always allowed. Operators are +/// refused even in `wm_deployers`: the datatable-query exemption lets them reach this route +/// without being able to deploy anything. +pub(crate) async fn preview_on_behalf_of( + authed: &ApiAuthed, + requested: Option<&str>, + w_id: &str, + db: &DB, +) -> error::Result> { + let Some(requested) = requested.filter(|s| !s.is_empty()) else { + return Ok(None); + }; + let own = username_to_permissioned_as(&authed.username); + if requested == own || requested == authed.email { + return Ok(None); + } + if authed.is_operator || !windmill_common::can_preserve_on_behalf_of(authed) { + return Err(Error::PermissionDenied( + "Only admins and members of wm_deployers can run a preview on behalf of another user" + .to_string(), + )); + } + let permissioned_as = + windmill_common::resolve_on_behalf_of(None, Some(requested), true, authed, w_id, db) + .await? + .unwrap_or(own.clone()); + if permissioned_as == own { + return Ok(None); + } + let email = + windmill_common::users::get_email_from_permissioned_as_uncached(&permissioned_as, w_id, db) + .await?; + Ok(Some(OnBehalfOf { email, permissioned_as })) +} + +/// Audits the impersonation and opens the job's own logs with it, so whoever reads the run sees +/// who it ran as and who asked, not only the identity columns. +pub(crate) async fn audit_preview_on_behalf_of( + tx: &mut Transaction<'_, Postgres>, + authed: &ApiAuthed, + w_id: &str, + job_id: Uuid, + path: Option<&str>, + obo: &OnBehalfOf, +) -> error::Result<()> { + let mut params = HashMap::from([("on_behalf_of", obo.permissioned_as.as_str())]); + if let Some(path) = path { + params.insert("path", path); + } + audit_log( + &mut **tx, + authed, + "jobs.run.preview_on_behalf_of", + ActionKind::Execute, + w_id, + Some(&job_id.to_string()), + Some(params), + ) + .await?; + sqlx::query!( + "INSERT INTO job_logs (logs, job_id, workspace_id) VALUES ($1, $2, $3) ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)", + format!( + "Preview run on behalf of {} ({}), requested by {}\n", + obo.permissioned_as, obo.email, authed.email + ), + job_id, + w_id, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// The push identity and isolation level for a preview: the caller's own, or the requested +/// `on_behalf_of`'s, pushed through the root pool like a deployed `on_behalf_of` run. +fn preview_push_identity( + authed: &ApiAuthed, + user_db: &UserDB, + db: &DB, + obo: Option<&OnBehalfOf>, +) -> ( + String, + String, + Option, + PushIsolationLevel<'static>, +) { + match obo { + Some(obo) => ( + obo.email.clone(), + obo.permissioned_as.clone(), + None, + PushIsolationLevel::IsolatedRoot(db.clone()), + ), + None => ( + authed.email.clone(), + username_to_permissioned_as(&authed.username), + Some(authed.clone().into()), + PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), + ), + } +} + async fn run_preview_script( authed: ApiAuthed, Extension(db): Extension, @@ -8964,7 +9080,13 @@ async fn run_preview_script( require_path_read_access_for_preview(&authed, &preview.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); - let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let obo = preview_on_behalf_of(&authed, preview.on_behalf_of.as_deref(), &w_id, &db).await?; + let (email, permissioned_as, push_authed, tx) = + preview_push_identity(&authed, &user_db, &db, obo.as_ref()); + let audited_path = preview.path.clone(); + // A job token's end user is whoever ran the calling job, who never consented to that job's + // code reading data as them: only a direct caller, reading as themselves, may ask for it. + let viewer_data_access = authed.job_id.is_none() && preview.viewer_data_access.unwrap_or(false); let preview_args = preview.args.unwrap_or_default(); let mut extra = HashMap::new(); @@ -9010,8 +9132,8 @@ async fn run_preview_script( }, push_args, authed.display_username(), - &authed.email, - username_to_permissioned_as(&authed.username), + &email, + permissioned_as, authed.token_prefix.as_deref(), authed.username_override.as_deref(), scheduled_for, @@ -9028,7 +9150,7 @@ async fn run_preview_script( run_query.timeout, None, None, - Some(&authed.clone().into()), + push_authed.as_ref(), false, end_user_email, authed.trigger_or_fallback(None), @@ -9036,6 +9158,19 @@ async fn run_preview_script( scope_ceiling.as_deref(), ) .await?; + let mut tx = tx; + if let Some(obo) = obo.as_ref() { + audit_preview_on_behalf_of(&mut tx, &authed, &w_id, uuid, audited_path.as_deref(), obo) + .await?; + } + if viewer_data_access { + sqlx::query!( + "UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1", + uuid + ) + .execute(&mut *tx) + .await?; + } tx.commit().await?; Ok((StatusCode::CREATED, uuid.to_string())) @@ -9387,7 +9522,11 @@ async fn run_bundle_preview_script( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); - let ltx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let obo = + preview_on_behalf_of(&authed, preview.on_behalf_of.as_deref(), &w_id, &db).await?; + let (email, permissioned_as, push_authed, ltx) = + preview_push_identity(&authed, &user_db, &db, obo.as_ref()); + let audited_path = preview.path.clone(); let args = preview.args.unwrap_or_default(); @@ -9435,8 +9574,8 @@ async fn run_bundle_preview_script( }), push_args, authed.display_username(), - &authed.email, - username_to_permissioned_as(&authed.username), + &email, + permissioned_as, authed.token_prefix.as_deref(), authed.username_override.as_deref(), scheduled_for, @@ -9453,7 +9592,7 @@ async fn run_bundle_preview_script( run_query.timeout, None, None, - Some(&authed.clone().into()), + push_authed.as_ref(), false, end_user_email, authed.trigger_or_fallback(None), @@ -9461,6 +9600,18 @@ async fn run_bundle_preview_script( scope_ceiling.as_deref(), ) .await?; + let mut ntx = ntx; + if let Some(obo) = obo.as_ref() { + audit_preview_on_behalf_of( + &mut ntx, + &authed, + &w_id, + uuid, + audited_path.as_deref(), + obo, + ) + .await?; + } job_id = Some(uuid); tx = Some(ntx); } @@ -10138,7 +10289,9 @@ async fn run_preview_flow_job( } let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(raw_flow.tag.clone()); - let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let obo = preview_on_behalf_of(&authed, raw_flow.on_behalf_of.as_deref(), &w_id, &db).await?; + let (email, permissioned_as, push_authed, tx) = + preview_push_identity(&authed, &user_db, &db, obo.as_ref()); let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false); let flow_path = raw_flow.path.clone().unwrap_or_default(); @@ -10171,8 +10324,8 @@ async fn run_preview_flow_job( }, PushArgs::from(&flow_args), authed.display_username(), - &authed.email, - username_to_permissioned_as(&authed.username), + &email, + permissioned_as, authed.token_prefix.as_deref(), authed.username_override.as_deref(), scheduled_for, @@ -10189,7 +10342,7 @@ async fn run_preview_flow_job( None, None, None, - Some(&authed.clone().into()), + push_authed.as_ref(), false, end_user_email, authed.trigger_or_fallback(None), @@ -10198,6 +10351,11 @@ async fn run_preview_flow_job( ) .await?; + if let Some(obo) = obo.as_ref() { + let audited_path = Some(flow_path.as_str()).filter(|p| !p.is_empty()); + audit_preview_on_behalf_of(&mut tx, &authed, &w_id, uuid, audited_path, obo).await?; + } + // Set memory_id if provided (for agent memory) if let Some(memory_id) = run_query.memory_key(&w_id, &flow_path) { set_flow_memory_id(&mut tx, uuid, memory_id).await?; diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index a266834f41..98339865be 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -19,7 +19,7 @@ import { toSyncRootRelativePath, validatePath, } from "../../core/context.ts"; -import { resolve, track_job, pollForJobResult } from "../script/script.ts"; +import { resolve, track_job, pollForJobResult, ensurePreviewOnBehalfOfSupported } from "../script/script.ts"; import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts"; import { SyncOptions, @@ -639,6 +639,7 @@ async function preview( remote?: boolean; step?: string; tag?: string; + onBehalfOf?: string; } & SyncOptions, flowPath: string ) { @@ -653,6 +654,7 @@ async function preview( } const workspace = await resolveWorkspace(opts); await requireLogin(opts); + await ensurePreviewOnBehalfOfSupported(workspace.workspaceId, opts.onBehalfOf); const codebases = useLocalPathScripts ? listSyncCodebases(opts) : []; const argPath = flowPath; @@ -758,7 +760,7 @@ async function preview( // when `useLocalPathScripts` is set, and tempScriptRefs covers relative // imports in inline scripts. if (opts.step) { - await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent, opts.tag); + await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent, opts.tag, opts.onBehalfOf); return; } @@ -775,6 +777,7 @@ async function preview( args: input, tag: opts.tag, temp_script_refs: tempScriptRefs, + on_behalf_of: opts.onBehalfOf, }, }); @@ -808,6 +811,7 @@ async function previewStep( tempScriptRefs: Record | undefined, silent: boolean, tag: string | undefined, + onBehalfOf: string | undefined, ) { const module = findStepInFlowValue(localFlow.value, stepId); if (!module) { @@ -841,6 +845,7 @@ async function previewStep( args, tag, temp_script_refs: tempScriptRefs, + on_behalf_of: onBehalfOf, }, }); } else if (moduleValue?.type === "script") { @@ -868,9 +873,15 @@ async function previewStep( args, tag, temp_script_refs: tempScriptRefs, + on_behalf_of: onBehalfOf, }, }); } else if (moduleValue?.type === "flow") { + if (onBehalfOf) { + throw new Error( + `--on-behalf-of does not apply to step '${stepId}': a flow step runs the deployed flow ${moduleValue.path} by path, and this flag cannot override its identity.` + ); + } log.info(colors.yellow(`Previewing step '${stepId}' (flow ${moduleValue.path})...`)); jobId = await wmill.runFlowByPath({ workspace: workspace.workspaceId, @@ -1216,6 +1227,10 @@ const command = new Command() "--tag ", "Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag)." ) + .option( + "--on-behalf-of ", + "Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers." + ) .action(preview as any) .command( "generate-locks", diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index e6ec9cefd8..f38a73d697 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -1615,6 +1615,32 @@ export async function pollForJobResult( return await pollJobWithQueueLogging(workspace, jobId); } +/** + * A server that predates preview `on_behalf_of` drops the field and runs the preview as the + * caller, which would pass for a reproduction of the other identity's run. Probe it with an + * `identity` preview, which runs no code, before submitting any. + */ +export async function ensurePreviewOnBehalfOfSupported( + workspace: string, + onBehalfOf: string | undefined, +) { + if (!onBehalfOf) return; + const probeId = await wmill.runScriptPreview({ + workspace, + requestBody: { kind: "identity", args: {}, on_behalf_of: onBehalfOf }, + }); + const job = await wmill.getJob({ workspace, id: probeId }); + if ( + job.permissioned_as === onBehalfOf || + job.email?.toLowerCase() === onBehalfOf.toLowerCase() + ) { + return; + } + throw new Error( + `The server does not support --on-behalf-of: it would run the preview as ${job.permissioned_as}, not ${onBehalfOf}. Upgrade the Windmill instance.` + ); +} + async function show(opts: GlobalOptions, path: string) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -1878,6 +1904,7 @@ async function preview( data?: string; silent: boolean; tag?: string; + onBehalfOf?: string; } & SyncOptions, filePath: string ) { @@ -1889,6 +1916,7 @@ async function preview( opts = await mergeConfigWithConfigFile(opts); const workspace = await resolveWorkspace(opts); await requireLogin(opts); + await ensurePreviewOnBehalfOfSupported(workspace.workspaceId, opts.onBehalfOf); const argPath = filePath; filePath = toSyncRootRelativePath(filePath, cwdBeforeConfig); @@ -2058,6 +2086,7 @@ async function preview( kind: isTar ? "tarbundle" : "bundle", format: codebase?.format ?? "cjs", temp_script_refs: tempScriptRefs, + on_behalf_of: opts.onBehalfOf, }; form.append("preview", JSON.stringify(previewPayload)); form.append( @@ -2127,6 +2156,7 @@ async function preview( tag: opts.tag, modules: modules ?? undefined, temp_script_refs: tempScriptRefs, + on_behalf_of: opts.onBehalfOf, }, }); @@ -2277,6 +2307,10 @@ const command = new Command() "--tag ", "Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag)." ) + .option( + "--on-behalf-of ", + "Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers." + ) .action(preview as any) .command("new", "create a new script") .arguments(" ") diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 5f66bc9ae0..7feaec6983 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -8540,6 +8540,7 @@ flow related commands - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. - \`--step \` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - \`--tag \` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). + - \`--on-behalf-of \` - Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - \`flow new \` - create a new empty flow - \`--summary \` - flow summary - \`--description \` - flow description @@ -8938,6 +8939,7 @@ script related commands - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. A resource argument is the bare string $res: as its whole value, and a variable argument is the bare string $var: — not an object wrapper keyed on $res/$var, and not a plain path. - \`-s --silent\` - Do not output anything other than the final output. Useful for scripting. - \`--tag \` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). + - \`--on-behalf-of \` - Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - \`script new \` - create a new script - \`--summary \` - script summary - \`--description \` - script description diff --git a/frontend/src/lib/components/FlowLoopIterationPreview.svelte b/frontend/src/lib/components/FlowLoopIterationPreview.svelte index f136cbfe39..8da0ee390e 100644 --- a/frontend/src/lib/components/FlowLoopIterationPreview.svelte +++ b/frontend/src/lib/components/FlowLoopIterationPreview.svelte @@ -10,9 +10,14 @@ import FlowProgressBar from './flows/FlowProgressBar.svelte' import { CornerDownLeft, Play, RefreshCw, X } from 'lucide-svelte' import type { Schema } from '$lib/common' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() interface Props { open: boolean @@ -112,7 +117,12 @@ restartedFrom, undefined, undefined, - opWorkspace?.() + opWorkspace?.(), + previewOnBehalfOf( + operatingUser.current, + flowStore.val.on_behalf_of, + flowStore.val.on_behalf_of_email + ) ) } catch (err: any) { // `runFlowPreview` resolves a linked agent's draft first and refuses when it cannot be read. diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 151d35be77..e2de124c1d 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -48,6 +48,7 @@ useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() @@ -211,7 +212,12 @@ restartedFrom, conversationId, devTempScriptRefs?.(), - opWorkspace?.() + opWorkspace?.(), + previewOnBehalfOf( + operatingUser.current, + flowStore.val.on_behalf_of, + flowStore.val.on_behalf_of_email + ) ) jobId = newJobId isRunning = true diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index c59f4266b7..fa812cc6e4 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -268,7 +268,8 @@ args: Record, flow: OpenFlow & { tag?: string }, callbacks?: Callbacks, - path?: string + path?: string, + onBehalfOf?: string ): Promise { return abstractRun( () => @@ -278,7 +279,8 @@ args, value: flow.value, tag: flow.tag, - path + path, + on_behalf_of: onBehalfOf } }), callbacks @@ -336,7 +338,8 @@ flowPath?: string, modules?: Record | null, tempScriptRefs?: Record, - timeout?: number + timeout?: number, + previewAs?: { onBehalfOf?: string; viewerDataAccess?: boolean } ): Promise { return abstractRun( () => @@ -353,7 +356,9 @@ script_hash: hash, flow_path: flowPath, modules: modules ?? undefined, - temp_script_refs: tempScriptRefs + temp_script_refs: tempScriptRefs, + on_behalf_of: previewAs?.onBehalfOf, + viewer_data_access: previewAs?.viewerDataAccess || undefined } }), callbacks diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index b8fd43e9ff..e7a0769960 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -23,7 +23,11 @@ import { AGENT_OVERRIDE_KEYS, agentTestInputTransforms } from './flows/agentFormFields' import { sendUserToast } from '$lib/toast' import { quickjsReady } from '$lib/utils/quickjsEval.svelte' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' const operatingWorkspace = useOperatingWorkspace() @@ -57,6 +61,16 @@ } = getContext('FlowEditorContext') let previewBase = $derived($pathStore ?? '') + const operatingUser = useOperatingUser() + // A step runs as the flow does, so its test does too; a workspace script with its own + // `on_behalf_of` runs as that instead, in the flow as here. + let onBehalfOf = $derived( + previewOnBehalfOf( + operatingUser.current, + flowStore?.val?.on_behalf_of, + flowStore?.val?.on_behalf_of_email + ) + ) // Acting workspace when the flow editor runs in an AI session; else the nav workspace. let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace) @@ -126,7 +140,8 @@ previewBase, undefined, devTempScriptRefs?.(), - timeout + timeout, + { onBehalfOf } ) } else if (val.type == 'script') { const script = val.hash @@ -144,7 +159,15 @@ previewBase, undefined, undefined, - timeout + timeout, + { + onBehalfOf: + previewOnBehalfOf( + operatingUser.current, + script.on_behalf_of, + script.on_behalf_of_email + ) ?? onBehalfOf + } ) } else if (val.type == 'flow') { await jobLoader?.runFlowByPath(val.path, args, callbacks) @@ -219,7 +242,8 @@ schema }, callbacks, - previewBase + previewBase, + onBehalfOf ) } else if (val.type == 'aidecision') { const { schema } = await loadSchemaFromModule(mod, opWs) @@ -241,7 +265,8 @@ schema }, callbacks, - previewBase + previewBase, + onBehalfOf ) } else { throw Error('Not supported module type') diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index b11fd34362..247382d6d4 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -128,6 +128,7 @@ import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights' + import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' const triggerLock = useTriggerLock() const editRights = useEditRights() @@ -2645,6 +2646,11 @@ {:else} ('AppViewerContext') + const operatingUser = useOperatingUser() const editorContext = getContext('AppEditorContext') const iterContext = getContext('ListWrapperContext') @@ -530,7 +534,8 @@ force_viewer_one_of_fields: !isEditor ? undefined : undefinedIfEmpty(oneOfRunnableInputs), force_viewer_allow_user_resources: !isEditor ? undefined - : undefinedIfEmpty(allowUserResources) + : undefinedIfEmpty(allowUserResources), + on_behalf_of: isEditor ? appPreviewOnBehalfOf(operatingUser.current, policy) : undefined } return requestBody } diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index 3efb4b30f0..3cac53cfe4 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -207,7 +207,8 @@ export async function runFlowPreview( tempScriptRefs?: Record, // The session's acting workspace when previewing inside an AI-session flow // editor; falls back to the navigation workspace for full-page previews. - workspace?: string + workspace?: string, + onBehalfOf?: string ) { const ws = workspace ?? get(workspaceStore) ?? '' const value = await withAgentDrafts(flow.value, ws) @@ -219,7 +220,8 @@ export async function runFlowPreview( path: path, tag: flow.tag, restarted_from: restartedFrom, - temp_script_refs: tempScriptRefs + temp_script_refs: tempScriptRefs, + on_behalf_of: onBehalfOf }, memoryId: conversationId }) diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index 9f675664b1..31c0623e49 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -16,6 +16,8 @@ jobsById?: Record editor: boolean workspace: string + /** Editor only: identity the previewed runnables run as (see `appPreviewOnBehalfOf`). */ + previewOnBehalfOf?: string /** * Restrict waitJob/getJob/streamJob to job ids launched by this app * instance (WIN-2006): a SANDBOXED bundle must not read arbitrary @@ -45,6 +47,7 @@ jobsById = $bindable({}), editor, workspace, + previewOnBehalfOf, gateJobIds = true, extraSourceWindow }: Props = $props() @@ -155,7 +158,9 @@ ) ) : undefined, - force_viewer_delete_after_secs: editor ? runnable?.delete_after_secs : undefined + force_viewer_delete_after_secs: editor ? runnable?.delete_after_secs : undefined, + on_behalf_of: editor ? previewOnBehalfOf : undefined, + viewer_data_access: editor ? runnable?.viewer_data_access || undefined : undefined }, undefined ) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8e02faf291..e41d0e5fd1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -53,6 +53,7 @@ import { rawAppLintStore } from './lintStore' import { dbSchemas, userStore } from '$lib/stores' import { useActingUser } from '$lib/actingUser.svelte' + import { appPreviewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' import RawAppSdkConsent from './RawAppSdkConsent.svelte' import { hasStoredSdkConsent, @@ -260,6 +261,7 @@ // Everything under the editor acts on it too (see operatingWorkspace.svelte.ts). setOperatingWorkspace(() => opWorkspace) const operatingUser = useActingUser(() => opWorkspace) + let previewOnBehalfOf = $derived(appPreviewOnBehalfOf(operatingUser.current, policy)) // The path autosaves land on, which is what the session preview loads the app by. const draftStoragePath = $derived(autosavePath ?? liveEditorDraftStoragePath) @@ -2599,6 +2601,7 @@ addEventListener('message', function (e) { bind:jobsById {runnables} {path} + {previewOnBehalfOf} gateJobIds={sandboxed} extraSourceWindow={() => externalPreviewWindow} /> @@ -2806,6 +2809,7 @@ addEventListener('message', function (e) { {#if selectedRunnable !== undefined} { diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte index 73fe0852f4..810beda674 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte @@ -21,7 +21,8 @@ import SchemaForm from '../SchemaForm.svelte' import RunnableJobPanelInner from '../apps/editor/RunnableJobPanelInner.svelte' import JobLoader from '../JobLoader.svelte' - import type { Job, OpenFlow, ScriptLang } from '$lib/gen' + import { FlowService, type Job, type OpenFlow, type ScriptLang } from '$lib/gen' + import { getScriptByPath } from '$lib/scripts' import { slide } from 'svelte/transition' import { DebugToolbar, DebugPanel, debugState } from '$lib/components/debug' import LogViewer from '$lib/components/LogViewer.svelte' @@ -50,6 +51,8 @@ runnable: Runnable id: string appPath: string + /** Identity the test runs as instead of the user (see `appPreviewOnBehalfOf`). */ + previewOnBehalfOf?: string lastDeployedCode?: string | undefined /** Called when code is selected in the editor */ onSelectionChange?: ( @@ -63,7 +66,13 @@ ) => void } - let { runnable = $bindable(), id, appPath, onSelectionChange }: Props = $props() + let { + runnable = $bindable(), + id, + appPath, + previewOnBehalfOf, + onSelectionChange + }: Props = $props() const dispatch = createEventDispatcher() @@ -179,7 +188,15 @@ runnable.inlineScript?.content ?? '', runnable.inlineScript?.language, args, - undefined + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { onBehalfOf: previewOnBehalfOf, viewerDataAccess: runnable.viewer_data_access } ) } else if (isRunnableByPath(runnable)) { if (jobLoader && isRunnableByPath(runnable)) { @@ -189,12 +206,48 @@ sendUserToast('Hub flow preview is still loading', true) return } - await jobLoader.runFlowPreview(args, hubFlowPreview, undefined, runnable.path) + await jobLoader.runFlowPreview( + args, + hubFlowPreview, + undefined, + runnable.path, + previewOnBehalfOf + ) + } else if (previewOnBehalfOf) { + // The deployed app runs a path runnable as the app's identity, which a run + // by path cannot ask for: preview the deployed definition instead. + const flow = await FlowService.getFlowByPath({ workspace: opWs!, path: runnable.path }) + await jobLoader.runFlowPreview( + args, + flow, + undefined, + runnable.path, + previewOnBehalfOf + ) } else { await jobLoader.runFlowByPath(runnable.path, args) } } else if (runnable.runType == 'script' || runnable.runType == 'hubscript') { - await jobLoader.runScriptByPath(runnable.path, args) + if (previewOnBehalfOf) { + const script = await getScriptByPath(runnable.path, opWs) + await jobLoader.runPreview( + runnable.path, + script.content, + script.language, + args, + script.tag, + script.lock, + script.hash, + undefined, + undefined, + undefined, + undefined, + undefined, + { onBehalfOf: previewOnBehalfOf } + ) + } else { + await jobLoader.runScriptByPath(runnable.path, args) + } } } } diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte index 891fcfa7c8..6143d367d2 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte @@ -10,6 +10,7 @@ runnables: Record selectedRunnable: string | undefined appPath: string + previewOnBehalfOf?: string /** Called when code is selected in the editor */ onSelectionChange?: ( selection: { @@ -26,6 +27,7 @@ runnables = $bindable(), selectedRunnable = $bindable(), appPath, + previewOnBehalfOf, onSelectionChange }: Props = $props() @@ -38,6 +40,7 @@ {#key selectedRunnable} { createScriptFromInlineScript(selectedRunnable ?? '', e.detail, opWs ?? '', appPath) }} diff --git a/frontend/src/lib/scripts.ts b/frontend/src/lib/scripts.ts index f9f1fa775e..eb0b3c48a5 100644 --- a/frontend/src/lib/scripts.ts +++ b/frontend/src/lib/scripts.ts @@ -248,6 +248,8 @@ export async function getScriptByPath( lock?: string created_at?: string hash?: string + on_behalf_of?: string + on_behalf_of_email?: string }> { if (path.startsWith('hub/')) { const { content, language, schema, lockfile } = await ScriptService.getHubScriptByPath({ path }) @@ -277,7 +279,9 @@ export async function getScriptByPath( concurrency_time_window_s: script.concurrency_time_window_s, lock: script.lock, hash: script.hash, - created_at: script.created_at + created_at: script.created_at, + on_behalf_of: script.on_behalf_of, + on_behalf_of_email: script.on_behalf_of_email } } } diff --git a/frontend/src/lib/utils/previewOnBehalfOf.ts b/frontend/src/lib/utils/previewOnBehalfOf.ts new file mode 100644 index 0000000000..5ca4d9133c --- /dev/null +++ b/frontend/src/lib/utils/previewOnBehalfOf.ts @@ -0,0 +1,33 @@ +type ActingUser = + | { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean; groups?: string[] } + | undefined + +/** + * The identity an editor preview should run as: the runnable's configured run-as identity, so a + * test behaves like the current deployed run. Only admins and `wm_deployers` may preview as + * someone else, and never operators (the server refuses everyone else); anyone else previews as + * themselves, which is also what their own save would record. + */ +export function previewOnBehalfOf( + user: ActingUser, + onBehalfOf: string | null | undefined, + onBehalfOfEmail?: string | null +): string | undefined { + const canPreserve = + !user?.operator && + (!!user?.is_admin || !!user?.is_super_admin || !!user?.groups?.includes('wm_deployers')) + if (!canPreserve) return undefined + return onBehalfOf || onBehalfOfEmail || undefined +} + +/** An app's runnables run as its policy identity unless it is in viewer mode, which runs them as + * whoever views it — the editor's user, in a preview. */ +export function appPreviewOnBehalfOf( + user: ActingUser, + policy: + | { execution_mode?: string; on_behalf_of?: string; on_behalf_of_email?: string } + | undefined +): string | undefined { + if (!policy || policy.execution_mode === 'viewer') return undefined + return previewOnBehalfOf(user, policy.on_behalf_of, policy.on_behalf_of_email) +} diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index a51e592bec..c2d1c39ab9 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -183,6 +183,7 @@ flow related commands - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. - `--step ` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - `--tag ` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). + - `--on-behalf-of ` - Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - `flow new ` - create a new empty flow - `--summary ` - flow summary - `--description ` - flow description @@ -581,6 +582,7 @@ script related commands - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. A resource argument is the bare string $res: as its whole value, and a variable argument is the bare string $var: — not an object wrapper keyed on $res/$var, and not a plain path. - `-s --silent` - Do not output anything other than the final output. Useful for scripting. - `--tag ` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). + - `--on-behalf-of ` - Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - `script new ` - create a new script - `--summary ` - script summary - `--description ` - script description diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 69112aed5b..1e66094664 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3887,6 +3887,7 @@ flow related commands - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. - \`--step \` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - \`--tag \` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). + - \`--on-behalf-of \` - Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - \`flow new \` - create a new empty flow - \`--summary \` - flow summary - \`--description \` - flow description @@ -4285,6 +4286,7 @@ script related commands - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. A resource argument is the bare string $res: as its whole value, and a variable argument is the bare string $var: — not an object wrapper keyed on $res/$var, and not a plain path. - \`-s --silent\` - Do not output anything other than the final output. Useful for scripting. - \`--tag \` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). + - \`--on-behalf-of \` - Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - \`script new \` - create a new script - \`--summary \` - script summary - \`--description \` - script description diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 4c67c57d9a..30fb9b93e3 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -188,6 +188,7 @@ flow related commands - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. - `--step ` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - `--tag ` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). + - `--on-behalf-of ` - Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - `flow new ` - create a new empty flow - `--summary ` - flow summary - `--description ` - flow description @@ -586,6 +587,7 @@ script related commands - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. A resource argument is the bare string $res: as its whole value, and a variable argument is the bare string $var: — not an object wrapper keyed on $res/$var, and not a plain path. - `-s --silent` - Do not output anything other than the final output. Useful for scripting. - `--tag ` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). + - `--on-behalf-of ` - Run the preview as this user or group (an email, u/ or g/) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - `script new ` - create a new script - `--summary ` - script summary - `--description ` - script description From 813c080d4684eecd144ef82706b04339c8f8d35a Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 15 Oct 2026 12:54:00 +0200 Subject: [PATCH 34/49] feat: sandbox new low-code and raw apps by default (#11760) * feat: sandbox new low-code and raw apps by default Co-Authored-By: Claude Opus 5.5 * fix: navigate the top page from plain links in a sandboxed low-code app Co-Authored-By: Claude Opus 5.5 * fix: allow clipboard and fullscreen in sandboxed raw-app frames Co-Authored-By: Claude Opus 5.5 * fix: leave non-http links to the browser in the sandboxed app frame Co-Authored-By: Claude Opus 5.5 * fix: let a link's own click handlers run before relaying it out of the frame Co-Authored-By: Claude Opus 5.5 * fix: read link attributes so SVG anchors relay correctly Co-Authored-By: Claude Opus 5.5 * refactor: tidy the app deploy drawer to the brand guidelines Co-Authored-By: Claude Opus 5.5 * refactor: show enabled frontend api scopes as badges, rename runs-as section Co-Authored-By: Claude Opus 5.5 * fix: keep frontend api access collapsed by default, badges next to its title Co-Authored-By: Claude Opus 5.5 * refactor: lead the run-on-behalf-of section with the user picker Co-Authored-By: Claude Opus 5.5 * docs: explain publisher execution and WM_END_USER_EMAIL in the run-on-behalf-of tooltip Co-Authored-By: Claude Opus 5.5 * docs: shorten the run-on-behalf-of tooltip, mention viewer data access for raw apps Co-Authored-By: Claude Opus 5.5 * docs: tell the AI what breaks in a sandboxed raw app and that turning it off is a security risk Co-Authored-By: Claude Opus 5.5 * feat: warn in the deploy drawer when an app is not isolated Co-Authored-By: Claude Opus 5.5 * refactor: drop the sandbox toggle help line Co-Authored-By: Claude Opus 5.5 * docs: say when to turn sandbox isolation off in the toggle tooltip Co-Authored-By: Claude Opus 5.5 * refactor: show who a non-deployer's app runs as, read-only Co-Authored-By: Claude Opus 5.5 * fix: show a new app runs as the deployer, gray read-only user line Co-Authored-By: Claude Opus 5.5 * refactor: read-only on-behalf-of display as an opt-in selector prop Co-Authored-By: Claude Opus 5.5 * fix: show a folder's default run-as user on a new app instead of the deployer Co-Authored-By: Claude Opus 5.5 * fix: deploy an app under a group folder default without a mismatched email Co-Authored-By: Claude Opus 5.5 * revert: leave a new app's run-as user unselected, as on main Co-Authored-By: Claude Opus 5.5 * feat: warn in the app deploy drawer when a deploy changes the run-as user Co-Authored-By: Claude Opus 5.5 * fix: snapshot the deployed policy so later edits don't change the saved app Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cli/src/commands/app/new.ts | 1 + cli/src/guidance/skills.gen.ts | 4 +- .../lib/components/OnBehalfOfSelector.svelte | 141 +-- .../apps/editor/AppEditorHeader.svelte | 4 +- .../apps/editor/AppEditorHeaderDeploy.svelte | 852 +++++++++--------- .../apps/editor/PublicAppFrame.svelte | 53 +- .../components/copilot/chat/global/core.ts | 1 + .../components/raw_apps/RawAppEditor.svelte | 3 +- .../components/raw_apps/RawAppPreview.svelte | 1 + .../raw_apps/RawAppTemplatePicker.svelte | 3 +- .../sessions/sessionRuntime.svelte.ts | 3 +- frontend/src/lib/utils_draft_deploy.ts | 2 +- .../(logged)/apps/edit/[...path]/+page.svelte | 4 +- .../apps_raw/edit/[...path]/+page.svelte | 5 +- system_prompts/auto-generated/prompts.ts | 5 +- .../auto-generated/skills/raw-app/SKILL.md | 4 +- system_prompts/base/raw-app.md | 7 +- 17 files changed, 586 insertions(+), 507 deletions(-) diff --git a/cli/src/commands/app/new.ts b/cli/src/commands/app/new.ts index 40cc2a4416..68ed636640 100644 --- a/cli/src/commands/app/new.ts +++ b/cli/src/commands/app/new.ts @@ -593,6 +593,7 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName}; // Create raw_app.yaml with data configuration const rawAppConfig: Record = { summary, + sandbox: true, }; // Add data configuration if present diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 7feaec6983..6b3c2822ae 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6920,7 +6920,9 @@ Either one lets those people run the app's backend runnables, so never open an a ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. + +Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry \`Origin: null\`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or \`localStorage\`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. In \`raw_app.yaml\`: \`\`\`yaml diff --git a/frontend/src/lib/components/OnBehalfOfSelector.svelte b/frontend/src/lib/components/OnBehalfOfSelector.svelte index 67648936ae..99ce21714e 100644 --- a/frontend/src/lib/components/OnBehalfOfSelector.svelte +++ b/frontend/src/lib/components/OnBehalfOfSelector.svelte @@ -26,7 +26,7 @@ - e.detail && loadUsers()} -> - {#snippet trigger()} - - - {#if selectedDisplayName} - {selectedDisplayName} - {/if} - {#if isFolderDefaultSelected} - + + {myDisplayName} + +{:else} + e.detail && loadUsers()} + > + {#snippet trigger()} + + + {#if selectedDisplayName} + {selectedDisplayName} + {/if} + {#if isFolderDefaultSelected} + + folder default + + {/if} + + {/snippet} + {#snippet content({ close: closePopover })} +
    +
    {label}
    + + {#if targetValue} + + {/if} + + + - {/if} - - - - -
    - {/snippet} -
    +
    + {/snippet} + +{/if} - { - e.stopPropagation() - }, - onkeyup: () => { - if (appPath == '' && summary?.length > 0 && !dirtyPath) { - path?.setName( - summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - } - }} - bind:value={summary} - /> -
    -
    - -
    - -
    - - -
    -
    - - +
    + {#if !onLatest} + + {headUnknown + ? "Deploying may overwrite changes made by other users. Press 'Deploy' to see diff." + : "By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff."} + + {/if} -
    - - A viewer of the app will execute the runnables of the app on behalf of the publisher (you) - - It ensures that all required resources/runnable visible for publisher but not for viewer at time - of creating the app would prevent the execution of the app. To guarantee tight security, a - policy is computed at time of deployment of the app which only allow the scripts/flows referred - to in the app to be called on behalf of. Furthermore, static parameters are not overridable. - Hence, users will only be able to use the app as intended by the publisher without risk for - leaking resources not used in the app. - - {#if canPreserve} -
    - Because you are either an admin or part of the {WM_DEPLOYERS_GROUP} group, you can select another - user to run this app on behalf of. Once deployed the app will be run on behalf of - { - onBehalfOfChoice = choice - if (choice === 'me') { - policy.on_behalf_of_email = $userStore?.email - policy.on_behalf_of = `u/${$userStore?.username}` - customOnBehalfOfEmail = '' - preserveOnBehalfOf = false - } else if (choice === 'target') { - policy.on_behalf_of_email = savedOnBehalfOfEmail - policy.on_behalf_of = savedOnBehalfOf - customOnBehalfOfEmail = '' - preserveOnBehalfOf = true - } else if (choice === 'custom' && details) { - policy.on_behalf_of_email = details.email - policy.on_behalf_of = details.permissionedAs - customOnBehalfOfEmail = details.email - preserveOnBehalfOf = true +
    +
    + + + { + e.stopPropagation() + }, + onkeyup: () => { + if (appPath == '' && summary?.length > 0 && !dirtyPath) { + path?.setName( + summary + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/-+/g, '_') + .replace(/^-|-$/g, '') + ) + } } }} - kind="app" - {canPreserve} - customValue={customOnBehalfOfEmail} - isDeployment={false} + bind:value={summary} + /> + +
    +
    + +
    - {/if} - - -
    - -
    -

    Sandbox isolation

    - Alpha -
    -
    - { - policy.sandbox = e.detail || undefined - // Counted where the toggle is flipped rather than where the policy is - // persisted: a not-yet-deployed app only mutates it locally, and skipping - // those would read as unused in the case where it is picked up front. - logFeatureUsage('app_sandbox', 'toggled', { - key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}` - }) - // Frontend API access exists only for a sandboxed app, so turning - // isolation off drops the declared scopes with it rather than leaving - // them set but inert. - if (!e.detail) { - policy.frontend_sdk_scopes = undefined - } - // A not-yet-deployed app has no row to PATCH — `setPublishState` (POST - // /apps/update) would 404. The flag rides along in the `policy` the first - // deploy sends (createApp), so here we only mutate it locally. Persist - // incrementally once the app exists. - if (savedApp && !newApp) { - setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') - } - }} - disabled={!savedApp || $operatorBuilderApps} - /> - {#if $operatorBuilderApps} -
    - Required for apps built by operators, and cannot be turned off. +
    + +
    - {/if} -
    - Controls what the app's browser-side code can reach in each viewer's browser — distinct from the - on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code - uses the viewer's own session; enable it to confine the app to a narrowly-scoped token instead, - on every surface (public URL and in-workspace). Leave it off if the app needs full browser - features (IndexedDB, third-party auth/SDKs, OAuth redirects).
    - {#if newApp} -
    Takes effect when you first deploy this app.
    - {/if} - {#if policy.sandbox == true} -
    - - Sandbox isolation is in alpha. After enabling, open the app from its public URL to confirm - it still works, and report any broken behavior. - -
    - {/if} -
    -{#if rawApp && policy.sandbox == true} -

    Frontend API access

    -
    -
    - Let the app's frontend code call the Windmill API through the windmill-client - SDK, authenticated as the viewer (unlike runnables, which run on behalf of the - publisher). Each viewer is asked to approve the scopes below before the app runs. Grant only - what the app needs: its code — or an XSS bug in it — can use them as that viewer. Add - windmill-client to the app's dependencies to import it; it configures itself from - the token handed to the bundle. -
    - {#each FRONTEND_SDK_SCOPES as scope (scope.value)} -
    - { - const current: string[] = policy.frontend_sdk_scopes ?? [] - const next = e.detail - ? [...current, scope.value] - : current.filter((s) => s !== scope.value) - // Keep the curated order so the consent banner and the stored - // consent compare stably across deploys. - const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => next.includes(s)) - policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined - // Same as sandbox: a not-yet-deployed app has no row to PATCH, so the - // scopes ride along in the first deploy's policy instead. - if (savedApp && !newApp) { - setPublishState('Frontend API access updated') +
    +
    +
    + { + onBehalfOfChoice = choice + if (choice === 'me') { + policy.on_behalf_of_email = $userStore?.email + policy.on_behalf_of = `u/${$userStore?.username}` + customOnBehalfOfEmail = '' + preserveOnBehalfOf = false + } else if (choice === 'target') { + policy.on_behalf_of_email = savedOnBehalfOfEmail + policy.on_behalf_of = savedOnBehalfOf + customOnBehalfOfEmail = '' + preserveOnBehalfOf = true + } else if (choice === 'custom' && details) { + policy.on_behalf_of_email = details.email + policy.on_behalf_of = details.permissionedAs + customOnBehalfOfEmail = details.email + preserveOnBehalfOf = true } }} - disabled={!savedApp} + kind="app" + {canPreserve} + customValue={customOnBehalfOfEmail} + isDeployment={false} + myPermissionedAs={myOnBehalfOf} + readOnlyWithoutPreserve /> -
    {scope.description}
    + {#if onBehalfOfWillChange} + + will change from {savedOnBehalfOf} on deploy + {/if}
    - {/each} - {#if newApp} -
    Takes effect when you first deploy this app.
    - {/if} - {#if policy.frontend_sdk_scopes?.length} -
    - - A sandboxed app calls the API cross-origin, which older windmill-client versions - cannot do. An app bundled before this Windmill version fails with a CORS error until you deploy - it again, which re-bundles it against a current client. - -
    - {/if} -
    -{/if} - -{#if !hideSecretUrl} -

    Access

    - -
    - {#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'} - - Opening this app to anyone with the link is restricted to workspace admins and bypass users - by a workspace protection rule - -
    - {/if} - {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable} - - Opening this app to guests is restricted to workspace admins and bypass users by a workspace - protection rule - -
    - {/if} -
    - onAccessModeChange(e.detail)} - disabled={!savedApp} - > - {#snippet children({ item })} - - - - {/snippet} - -
    -
    - {#if policy.execution_mode == 'anonymous'} - Anyone holding the secret URL below can open this app without signing in. - {:else if policy.execution_mode == 'guest'} - {#if !guestsAvailable} - Guests are not available on Windmill Cloud, so this app still admits members only. They - require a self-hosted instance or a dedicated Windmill Cloud deployment. - {:else if guestUsage && !guestUsage.instance_enabled} - A superadmin has turned guests off for this instance, so this app still admits members - only. - {:else if guestAccessEnabled === undefined} - Checking whether this workspace allows guests… - {:else if guestAccessEnabled === false} - Guests are turned off for this workspace, so this app still admits members only. A - workspace admin can turn them on in the workspace settings. +
    + {#if canPreserve} + Viewers run the app's runnables as this user. {:else} - Anyone your identity provider authenticates can open this app without a Windmill account. - They join no workspace. Members of this workspace can open it too. - {#if guestUsage} - {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this instance - in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered - ? 'every four guests count as one seat' - : 'new guests are refused until the count drops'}. - {/if} + Only workspace admins and members of {WM_DEPLOYERS_GROUP} can pick another user. {/if} - {:else} - Only workspace members with read access on this app can open it. - {/if} -
    - {#if !savedApp || newApp} - - {:else if secretUrlHref} -
    - (embedMode = e.detail)} - options={{ left: 'URL', right: 'Embed' }} - />
    - - {:else} - {/if} -
    - {#if embedMode} - Paste this iframe snippet into another app. - {#if rawApp} - To embed it in a cross-origin isolated page, add wm_coep=on - Sets the cross-origin isolation headers (COEP) on the app, which such a page requires - of its iframes. The app then only loads cross-origin resources that send CORS or a - Cross-Origin-Resource-Policy header. to the URL. - {/if} - (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) - {:else} - Share this url directly, or switch to Embed to get an iframe snippet. - {/if}
    +
    - {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable} -
    -
    - Embed for your own authenticated users (guest JWT) -
    -
    - To open this app for a user your own product already authenticates, mint a short-lived JWT - in your backend and append it to the app URL as guest.<jwt>. Each token - is its own seatless guest, confined to this app — no shared secret and no Windmill - account, unlike the plain secret URL above. -
    -
    - Windmill verifies the token against the workspace's guest JWT key (Workspace settings → - Guests) — a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's - configured issuer (JWT_EXT_JWKS_URL) when no workspace key is set{/if}. Set - the public half there; in your backend, sign each token with the matching - private key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is - refused), carrying email, workspace_id = {opWs}, - app_path = {appPath} and exp (at most 24h ahead). -
    - -
    - Replace YOUR_GUEST_JWT with the token your backend signs per user. Past the instance's - free guest allowance a new guest email is refused (see the count above); guests already seen - in the window keep working. -
    +
    + { + policy.sandbox = e.detail || undefined + // Counted where the toggle is flipped rather than where the policy is + // persisted: a not-yet-deployed app only mutates it locally, and skipping + // those would read as unused in the case where it is picked up front. + logFeatureUsage('app_sandbox', 'toggled', { + key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}` + }) + // Frontend API access exists only for a sandboxed app, so turning + // isolation off drops the declared scopes with it rather than leaving + // them set but inert. + if (!e.detail) { + policy.frontend_sdk_scopes = undefined + } + // A not-yet-deployed app has no row to PATCH — `setPublishState` (POST + // /apps/update) would 404. The flag rides along in the `policy` the first + // deploy sends (createApp), so here we only mutate it locally. Persist + // incrementally once the app exists. + if (savedApp && !newApp) { + setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') + } + }} + disabled={!savedApp || $operatorBuilderApps} + /> + {#if $operatorBuilderApps || newApp} +
    + {#if $operatorBuilderApps} + Required for apps built by operators, and cannot be turned off. + {/if} + {#if newApp} + Takes effect when you first deploy this app. + {/if}
    {/if} - -
    - {#if !(actingUser?.is_admin || actingUser?.is_super_admin)} - - Custom path can only be set by workspace admins + {#if !$operatorBuilderApps && policy.sandbox != true} +
    + + The app's frontend code runs with each viewer's full Windmill permissions. A future + version will ask viewers to approve this. Keep isolation off only if the app needs it. -
    - {/if} - - {#if !$enterpriseLicense} - - {/if} - { - customPath = detail ? '' : undefined - if (customPath === undefined) { - customPathError = '' - } - }} - checked={customPath !== undefined} - options={{ - right: 'Use a custom URL' - }} - disabled={!$enterpriseLicense || !(actingUser?.is_admin || actingUser?.is_super_admin)} - /> +
    + {/if} - {#if customPath !== undefined} -
    -
    Custom path
    -
    - { - dirtyCustomPath = true - }} - /> -
    -
    Custom public URL
    -
    - + {#if rawApp && policy.sandbox == true} +
    +
    + {#snippet badge()} + {#if policy.frontend_sdk_scopes?.length} +
    + {#each FRONTEND_SDK_SCOPES.filter( (s) => policy.frontend_sdk_scopes.includes(s.value) ) as scope (scope.value)} + {scope.label} + {/each} +
    + {/if} + {/snippet} +
    + Let the app's frontend call the Windmill API as the viewer through the + windmill-client SDK, which it lists as a dependency. Grant only what the app + needs. +
    +
    + {#each FRONTEND_SDK_SCOPES as scope (scope.value)} +
    + { + const current: string[] = policy.frontend_sdk_scopes ?? [] + const next = e.detail + ? [...current, scope.value] + : current.filter((s) => s !== scope.value) + // Keep the curated order so the consent banner and the stored + // consent compare stably across deploys. + const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => + next.includes(s) + ) + policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined + // Same as sandbox: a not-yet-deployed app has no row to PATCH, so the + // scopes ride along in the first deploy's policy instead. + if (savedApp && !newApp) { + setPublishState('Frontend API access updated') + } + }} + disabled={!savedApp} + /> +
    {scope.description}
    +
    + {/each} +
    + {#if policy.frontend_sdk_scopes?.length} +
    + + A sandboxed app calls the API cross-origin, which older windmill-client + versions cannot do. An app bundled before this Windmill version fails with a CORS error + until you deploy it again, which re-bundles it against a current client. + +
    + {/if} +
    +
    + {/if} +
    -
    {dirtyCustomPath ? customPathError : ''} + {#if !hideSecretUrl} +
    +
    + {#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'} + + Opening this app to anyone with the link is restricted to workspace admins and bypass + users by a workspace protection rule + + {/if} + {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable} + + Opening this app to guests is restricted to workspace admins and bypass users by a + workspace protection rule + + {/if} +
    + onAccessModeChange(e.detail)} + disabled={!savedApp} + > + {#snippet children({ item })} + + + + {/snippet} + +
    +
    + {#if policy.execution_mode == 'anonymous'} + Anyone holding the secret URL below can open this app without signing in. + {:else if policy.execution_mode == 'guest'} + {#if !guestsAvailable} + Guests are not available on Windmill Cloud, so this app still admits members only. + They require a self-hosted instance or a dedicated Windmill Cloud deployment. + {:else if guestUsage && !guestUsage.instance_enabled} + A superadmin has turned guests off for this instance, so this app still admits members + only. + {:else if guestAccessEnabled === undefined} + Checking whether this workspace allows guests… + {:else if guestAccessEnabled === false} + Guests are turned off for this workspace, so this app still admits members only. A + workspace admin can turn them on in the workspace settings. + {:else} + Anyone your identity provider authenticates can open this app without a Windmill + account. They join no workspace. Members of this workspace can open it too. + {#if guestUsage} + {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this + instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered + ? 'every four guests count as one seat' + : 'new guests are refused until the count drops'}. + {/if} + {/if} + {:else} + Only workspace members with read access on this app can open it. + {/if} +
    +
    + +
    + {#if !savedApp || newApp} + + {:else if secretUrlHref} +
    + (embedMode = e.detail)} + options={{ left: 'URL', right: 'Embed' }} + /> +
    + + {:else} + {/if} +
    + {#if embedMode} + Paste this iframe snippet into another app. + {#if rawApp} + To embed it in a cross-origin isolated page, add wm_coep=on + Sets the cross-origin isolation headers (COEP) on the app, which such a page + requires of its iframes. The app then only loads cross-origin resources that send + CORS or a Cross-Origin-Resource-Policy header. to the URL. + {/if} + (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) + {:else} + Share this URL directly, or switch to Embed to get an iframe snippet. Only the latest + deployed version is served. + {/if} +
    +
    + + {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable} +
    +
    + Embed for your own authenticated users (guest JWT) +
    +
    + To open this app for a user your own product already authenticates, mint a short-lived + JWT in your backend and append it to the app URL as guest.<jwt>. Each + token is its own seatless guest, confined to this app. Unlike the plain secret URL + above, there is no shared secret and no Windmill account. +
    +
    + Windmill verifies the token against the workspace's guest JWT key (Workspace settings → + Guests): a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's + configured issuer (JWT_EXT_JWKS_URL) when no workspace key is set{/if}. + Set the public half there; in your backend, sign each token with the matching + private key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is + refused), carrying email, workspace_id = {opWs}, + app_path = {appPath} and exp (at most 24h ahead). +
    + +
    + Replace YOUR_GUEST_JWT with the token your backend signs per user. Past the + instance's free guest allowance a new guest email is refused (see the count above); guests + already seen in the window keep working. +
    {/if} -
    -
    - - You will still need to deploy the app to make visible the latest changes - - Embed this app in your own product to be used by your own users -{/if} +
    + {#if !(actingUser?.is_admin || actingUser?.is_super_admin)} + + Custom path can only be set by workspace admins + + {/if} +
    + { + customPath = detail ? '' : undefined + if (customPath === undefined) { + customPathError = '' + } + }} + checked={customPath !== undefined} + options={{ + right: 'Use a custom URL' + }} + disabled={!$enterpriseLicense || !(actingUser?.is_admin || actingUser?.is_super_admin)} + /> + {#if !$enterpriseLicense} + + {/if} +
    + + {#if customPath !== undefined} +
    + + { + dirtyCustomPath = true + } + }} + bind:value={customPath} + error={dirtyCustomPath ? customPathError : undefined} + /> + {#if dirtyCustomPath && customPathError} +
    {customPathError}
    + {/if} +
    +
    +
    Custom public URL
    + +
    + {/if} +
    + + Embed this app in your own product to be used by your own users + + {/if} +
    diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index eacb3652e1..4c391c0336 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -194,21 +194,38 @@ // Top-level navigation relay (WIN-2006): in-app links to other apps (navbar // "app" items) must navigate the top page — navigating inside the opaque // iframe would load the SPA cookieless. Consumed by PublicApp via gotoFn. - setContext( - EMBED_NAV_CONTEXT_KEY, - isViewer - ? { - navigateTop: (href: string) => { - try { - window.parent.postMessage( - { type: 'wm_embed_navigate', href }, - expectedEmbedderOrigin ?? '*' - ) - } catch (_) {} - } - } - : undefined - ) + function navigateTop(href: string) { + try { + window.parent.postMessage({ type: 'wm_embed_navigate', href }, expectedEmbedderOrigin ?? '*') + } catch (_) {} + } + setContext(EMBED_NAV_CONTEXT_KEY, isViewer ? { navigateTop } : undefined) + + // Plain links (markdown, HTML components) would otherwise load inside the + // cookieless frame, or put an external site in it. Bound on so the link's own + // handlers run first and can cancel it, and before SvelteKit's router on , + // which skips a prevented click. A link to this same page only changes ?query/#hash, + // which the hash relay mirrors, so it stays in the frame. + function relayLinkClick(e: MouseEvent) { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) + return + // Attributes, not properties: on an SVG those are SVGAnimatedString. + const a = (e.target as Element | null)?.closest?.('a[href]') + const target = a?.getAttribute('target') + if ( + !a || + a.hasAttribute('download') || + (target && !['_self', '_top', '_parent'].includes(target)) + ) + return + const url = new URL(a.getAttribute('href')!, document.baseURI) + // The embedder only relays http(s); mailto:, tel: and the like work in the frame. + if (url.protocol !== 'http:' && url.protocol !== 'https:') return + const sameOrigin = url.origin === window.location.origin + if (sameOrigin && url.pathname === window.location.pathname) return + e.preventDefault() + navigateTop(sameOrigin ? url.pathname + url.search + url.hash : url.href) + } function installHashRelay() { origPushState = history.pushState origReplaceState = history.replaceState @@ -568,6 +585,7 @@ loadAppPreview().catch(() => {}) window.addEventListener('message', handleViewerMessage) installHashRelay() + document.body.addEventListener('click', relayLinkClick) // Announce readiness so the embedder sends us the token. window.parent.postMessage({ type: 'wm_embed_ready' }, expectedEmbedderOrigin ?? '*') // This page is only ever loaded as the embedder's opaque iframe, which @@ -589,7 +607,10 @@ if (!BROWSER) return window.removeEventListener('message', handleViewerMessage) window.removeEventListener('message', handleEmbedderMessage) - if (isViewer) uninstallHashRelay() + if (isViewer) { + uninstallHashRelay() + document.body.removeEventListener('click', relayLinkClick) + } }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index c1437cb21f..fc28f6cd5b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6970,6 +6970,7 @@ async function initApp( summary, files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES)[framework] }, runnables: builder ? {} : structuredClone(STARTER_RUNNABLES), + policy: { sandbox: true }, ...(data && { data: { tables: (data.tables ?? []).map((ref) => appTableRef(data.datatable, data.schema, ref)), diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index e41d0e5fd1..2006aef442 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -464,7 +464,7 @@ addEventListener('message', function (e) { n.hidden = !e.data.text } else if (e.source === opener) frame.postMessage(e.data, '*') }) -<\/script>` +<\/script>` ], { type: 'text/html' } ) @@ -2937,6 +2937,7 @@ addEventListener('message', function (e) { title="App preview" src={previewShellUrl} sandbox={sandboxed ? RAW_APP_SANDBOX_FLAGS : undefined} + allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy={sandboxed ? 'no-referrer' : undefined} class="w-full flex-1 block" > diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index fa3e7e6211..36c3fb360c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -351,6 +351,7 @@ title="raw-app" src={iframeSrc} sandbox={sandboxAttr} + allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy={unsandboxed ? undefined : 'no-referrer'} onload={() => (loadedSrc = iframeSrc)} style="display: block; width: 100%; height: 100%; border: none" diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index a052e54f97..e56ef4bff6 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -298,7 +298,8 @@ ? $userStore?.username : `u/${$userStore?.username}`, on_behalf_of_email: $userStore?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true } if ($operatorBuilderApps) conformBuilderAppPolicy(policy, $userStore) return policy diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 6d4b7f568d..f40882b95b 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -1161,7 +1161,8 @@ function createRuntime(session: Session): SessionRuntime { policy: { on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`, on_behalf_of_email: user?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true }, summary: '', path, diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 611d5b96e8..44dec7f124 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -551,7 +551,7 @@ export async function deployDraft( // undefined so the backend preserves the existing route. The draft has no // custom_path, so admins fall back to the deployed route (`''` when none). const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) - const policy = r.policy ?? { execution_mode: 'publisher' } + const policy = r.policy ?? { execution_mode: 'publisher', sandbox: true } const requestBody = { value: appValue, summary: draftSummary ?? r.summary ?? '', diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 974d1c7f64..57e387ad20 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -108,7 +108,7 @@ itemKind: 'app', path }) - const emptyPolicy = { execution_mode: 'publisher' } as any + const emptyPolicy = { execution_mode: 'publisher', sandbox: true } as any // One-shot import handoff via $importStore (YAML/JSON or "Build app"). // Wrapped exports carry { summary, value, policy }; bare ones are the App value. const importRaw = $importStore @@ -246,7 +246,7 @@ summary: savedDraftApp?.summary ?? '', value: (savedDraftApp ?? {}) as App, path: page.params.path ?? '', - policy: { execution_mode: 'publisher' } as any, + policy: { execution_mode: 'publisher', sandbox: true } as any, custom_path: undefined, versions: undefined as any, id: 0 as any, diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index ef1265c37a..8956f42441 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -235,7 +235,8 @@ ? $userStore?.username : `u/${$userStore?.username}`, on_behalf_of_email: $userStore?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true } as any // Explicit path seed: the fork-a-draft handoff re-homes the source // path into the forker's namespace and passes it here. @@ -385,7 +386,7 @@ data: savedRawAppDraft?.data } backendApp.summary = savedRawAppDraft?.summary ?? '' - backendApp.policy = savedRawAppDraft?.policy ?? { execution_mode: 'publisher' } + backendApp.policy = savedRawAppDraft?.policy ?? { execution_mode: 'publisher', sandbox: true } backendApp.custom_path = savedRawAppDraft?.custom_path backendApp.path = page.params.path ?? '' } else if (savedRawAppDraft) { diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 1e66094664..507c3ed474 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1144,7 +1144,10 @@ When a deploy widens who may open the app, tell the user in plain words. ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. + +Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry \`Origin: null\`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or \`localStorage\`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. +You cannot turn isolation off; the user does it in the app's deploy settings. ## Reading data as the viewer diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index f4dce90269..630d650ed2 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -477,7 +477,9 @@ Either one lets those people run the app's backend runnables, so never open an a ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. + +Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. In `raw_app.yaml`: ```yaml diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index efe05f2c93..4350de4085 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -243,7 +243,12 @@ When a deploy widens who may open the app, tell the user in plain words. ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. + +Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. + +You cannot turn isolation off; the user does it in the app's deploy settings. + In `raw_app.yaml`: From 526da60da92832cc6e9c2942cea1f7729b3a05fd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 15 Oct 2026 13:36:34 +0200 Subject: [PATCH 35/49] feat: let raw app backend runnables import each other (#11777) * fix: iterate workspace picker nav items without spreading a NodeList Co-Authored-By: Claude Opus 5.5 * feat: let raw app backend runnables import each other Co-Authored-By: Claude Opus 5.5 * fix: preview only imported sibling runnables and reserve the main id Co-Authored-By: Claude Opus 5.5 * fix: resolve a module's workspace imports from the entrypoint's folder Co-Authored-By: Claude Opus 5.5 * fix: scope run-mode app scripts to their app and key bundles on module imports Co-Authored-By: Claude Opus 5.5 * fix: clone app_version_lite into forks with remapped app_script ids Co-Authored-By: Claude Opus 5.5 * docs: update the run-mode raw_code comment for app-scoped ids Co-Authored-By: Claude Opus 5.5 * fix: run module-bearing app scripts only from the deployed version Co-Authored-By: Claude Opus 5.5 * fix: run deployed inline code only through the deployed version's rows Co-Authored-By: Claude Opus 5.5 * fix: in-workspace app viewers run through the deployed rows Co-Authored-By: Claude Opus 5.5 * fix: let the app embed token read the app's lite value Co-Authored-By: Claude Opus 5.5 * fix: pass sibling modules to the agent-worker Python preview resolver Co-Authored-By: Claude Opus 5.5 * docs: list the lite route in the app embed scope comment Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 455f98540fb042d4f624dcec681dab88b2c8df22 This commit updates the EE repository reference after PR #883 was merged in windmill-ee-private. Previous ee-repo-ref: 927d3592a6b2127ffe16fb14eb47f5bd66afc026 New ee-repo-ref: 455f98540fb042d4f624dcec681dab88b2c8df22 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- ...bdd22c18a5f748497824d41b1b6eae975b091.json | 30 +++ ...242cf3a6287e7b4548c7f01ad888230c27013.json | 22 -- ...4efaa33600c3ecd266e8339b9bfb32ae18499.json | 27 +++ ...6ec152d4ee52b58f208f2fa1639eaa448c6ad.json | 18 -- ...f110dadb3db328f0589f7de27bcb418e70371.json | 28 +++ ...d04d8c95e1e87e75b9a07b01c03f3e3d4373.json} | 12 +- ...fe701ee064dd9c880ad4df89ec47279aff7de.json | 15 ++ ...8394cb91064948c1c3fbb21f9461b9869af0.json} | 26 ++- ...c838beaa5dfa5e3bcfcfeddee252ffe1334e.json} | 7 +- ...f47751aa5bff0741e98c00b3294d29dc36718.json | 24 +++ backend/ee-repo-ref.txt | 2 +- ...20261015063444_app_script_modules.down.sql | 1 + .../20261015063444_app_script_modules.up.sql | 1 + .../windmill-parser-py-imports/src/lib.rs | 41 +++- .../windmill-parser-py-imports/tests/tests.rs | 3 + backend/summarized_schema.txt | 2 +- backend/windmill-api-auth/src/scopes.rs | 9 +- .../windmill-api-workspaces/src/workspaces.rs | 82 ++++++- backend/windmill-api/openapi.yaml | 6 + backend/windmill-api/src/apps.rs | 84 ++++++-- backend/windmill-common/src/worker.rs | 12 +- backend/windmill-worker/loader.bun.js | 36 +++- backend/windmill-worker/src/bun_executor.rs | 20 ++ .../windmill-worker/src/python_executor.rs | 45 +++- .../windmill-worker/src/worker_lockfiles.rs | 200 +++++++++++++++--- cli/src/commands/app/dev.ts | 60 +++++- cli/src/guidance/skills.gen.ts | 4 + .../components/helpers/executeRunnable.ts | 13 +- .../apps/editor/InWorkspaceAppViewer.svelte | 10 +- .../raw_apps/RawAppBackgroundRunner.svelte | 6 +- .../RawAppInlineScriptRunnable.svelte | 14 +- .../raw_apps/RawAppInlineScriptsPanel.svelte | 1 + .../src/lib/components/raw_apps/utils.test.ts | 27 +++ frontend/src/lib/components/raw_apps/utils.ts | 45 +++- .../[workspace]/[...path]/+page.svelte | 3 +- system_prompts/auto-generated/prompts.ts | 4 + .../auto-generated/skills/raw-app/SKILL.md | 4 + system_prompts/base/raw-app.md | 4 + 38 files changed, 810 insertions(+), 138 deletions(-) create mode 100644 backend/.sqlx/query-0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091.json delete mode 100644 backend/.sqlx/query-1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013.json create mode 100644 backend/.sqlx/query-4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499.json delete mode 100644 backend/.sqlx/query-5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad.json create mode 100644 backend/.sqlx/query-73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371.json rename backend/.sqlx/{query-ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c.json => query-7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373.json} (51%) create mode 100644 backend/.sqlx/query-9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de.json rename backend/.sqlx/{query-34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9.json => query-a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0.json} (59%) rename backend/.sqlx/{query-0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b.json => query-c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e.json} (55%) create mode 100644 backend/.sqlx/query-f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718.json create mode 100644 backend/migrations/20261015063444_app_script_modules.down.sql create mode 100644 backend/migrations/20261015063444_app_script_modules.up.sql diff --git a/backend/.sqlx/query-0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091.json b/backend/.sqlx/query-0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091.json new file mode 100644 index 0000000000..e7b808f911 --- /dev/null +++ b/backend/.sqlx/query-0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT l.id IS NOT NULL as \"has_lite!\",\n coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',\n jsonb_build_object('id', $3::bigint)), false) as \"is_deployed_row!\"\n FROM app a\n LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.path = $1 AND a.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_lite!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "is_deployed_row!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091" +} diff --git a/backend/.sqlx/query-1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013.json b/backend/.sqlx/query-1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013.json deleted file mode 100644 index 5c43791411..0000000000 --- a/backend/.sqlx/query-1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT format('rawscript/%s', code_sha256) as \"path!: String\"\n FROM app_script WHERE id = $1 LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path!: String", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013" -} diff --git a/backend/.sqlx/query-4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499.json b/backend/.sqlx/query-4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499.json new file mode 100644 index 0000000000..cd2e6651b8 --- /dev/null +++ b/backend/.sqlx/query-4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_script (app, hash, lock, code, code_sha256, modules)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Bpchar", + "Text", + "Text", + "Bpchar", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499" +} diff --git a/backend/.sqlx/query-5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad.json b/backend/.sqlx/query-5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad.json deleted file mode 100644 index 3458d1068e..0000000000 --- a/backend/.sqlx/query-5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app_script (app, hash, lock, code, code_sha256)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Bpchar", - "Text", - "Text", - "Bpchar" - ] - }, - "nullable": [] - }, - "hash": "5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad" -} diff --git a/backend/.sqlx/query-73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371.json b/backend/.sqlx/query-73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371.json new file mode 100644 index 0000000000..f4092042dd --- /dev/null +++ b/backend/.sqlx/query-73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, value AS \"value!: serde_json::Value\" FROM app_version_lite WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "value!: serde_json::Value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371" +} diff --git a/backend/.sqlx/query-ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c.json b/backend/.sqlx/query-7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373.json similarity index 51% rename from backend/.sqlx/query-ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c.json rename to backend/.sqlx/query-7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373.json index 49fe5cf6b8..e5ee2e27e8 100644 --- a/backend/.sqlx/query-ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c.json +++ b/backend/.sqlx/query-7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT lock, code FROM app_script WHERE id = $1 LIMIT 1", + "query": "SELECT lock, code, modules AS \"modules: serde_json::Value\"\n FROM app_script WHERE id = $1 LIMIT 1", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "code", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "modules: serde_json::Value", + "type_info": "Jsonb" } ], "parameters": { @@ -21,8 +26,9 @@ }, "nullable": [ true, - false + false, + true ] }, - "hash": "ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c" + "hash": "7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373" } diff --git a/backend/.sqlx/query-9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de.json b/backend/.sqlx/query-9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de.json new file mode 100644 index 0000000000..6caeda5fc2 --- /dev/null +++ b/backend/.sqlx/query-9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_version_lite (id, value)\n SELECT * FROM UNNEST($1::bigint[], $2::jsonb[])\n ON CONFLICT (id) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "JsonbArray" + ] + }, + "nullable": [] + }, + "hash": "9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de" +} diff --git a/backend/.sqlx/query-34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9.json b/backend/.sqlx/query-a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0.json similarity index 59% rename from backend/.sqlx/query-34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9.json rename to backend/.sqlx/query-a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0.json index 07fa5c2adf..2abbc1a8a3 100644 --- a/backend/.sqlx/query-34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9.json +++ b/backend/.sqlx/query-a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0.json @@ -1,32 +1,42 @@ { "db_name": "PostgreSQL", - "query": "SELECT app, hash, lock, code, code_sha256\n FROM app_script\n WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", + "query": "SELECT id, app, hash, lock, code, code_sha256, modules\n FROM app_script\n WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", "describe": { "columns": [ { "ordinal": 0, - "name": "app", + "name": "id", "type_info": "Int8" }, { "ordinal": 1, + "name": "app", + "type_info": "Int8" + }, + { + "ordinal": 2, "name": "hash", "type_info": "Bpchar" }, { - "ordinal": 2, + "ordinal": 3, "name": "lock", "type_info": "Text" }, { - "ordinal": 3, + "ordinal": 4, "name": "code", "type_info": "Text" }, { - "ordinal": 4, + "ordinal": 5, "name": "code_sha256", "type_info": "Bpchar" + }, + { + "ordinal": 6, + "name": "modules", + "type_info": "Jsonb" } ], "parameters": { @@ -35,12 +45,14 @@ ] }, "nullable": [ + false, false, false, true, false, - false + false, + true ] }, - "hash": "34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9" + "hash": "a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0" } diff --git a/backend/.sqlx/query-0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b.json b/backend/.sqlx/query-c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e.json similarity index 55% rename from backend/.sqlx/query-0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b.json rename to backend/.sqlx/query-c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e.json index efe58b7a16..9779b00977 100644 --- a/backend/.sqlx/query-0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b.json +++ b/backend/.sqlx/query-c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO app_script (app, hash, lock, code, code_sha256)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id\n ", + "query": "\n INSERT INTO app_script (app, hash, lock, code, code_sha256, modules)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id\n ", "describe": { "columns": [ { @@ -15,12 +15,13 @@ "Bpchar", "Text", "Text", - "Bpchar" + "Bpchar", + "Jsonb" ] }, "nullable": [ false ] }, - "hash": "0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b" + "hash": "c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e" } diff --git a/backend/.sqlx/query-f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718.json b/backend/.sqlx/query-f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718.json new file mode 100644 index 0000000000..79b8e613ac --- /dev/null +++ b/backend/.sqlx/query-f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT format('rawscript/%s', s.code_sha256) as \"path!: String\"\n FROM app_script s JOIN app a ON a.id = s.app\n WHERE s.id = $1 AND a.path = $2 AND a.workspace_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 27ede3cc3c..6a195a07d1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -346bd5417537b76591465e63b9d15bd081e9228c +455f98540fb042d4f624dcec681dab88b2c8df22 diff --git a/backend/migrations/20261015063444_app_script_modules.down.sql b/backend/migrations/20261015063444_app_script_modules.down.sql new file mode 100644 index 0000000000..026e1e841a --- /dev/null +++ b/backend/migrations/20261015063444_app_script_modules.down.sql @@ -0,0 +1 @@ +ALTER TABLE app_script DROP COLUMN IF EXISTS modules; diff --git a/backend/migrations/20261015063444_app_script_modules.up.sql b/backend/migrations/20261015063444_app_script_modules.up.sql new file mode 100644 index 0000000000..aa8e6d5fb2 --- /dev/null +++ b/backend/migrations/20261015063444_app_script_modules.up.sql @@ -0,0 +1 @@ +ALTER TABLE app_script ADD COLUMN IF NOT EXISTS modules JSONB; diff --git a/backend/parsers/windmill-parser-py-imports/src/lib.rs b/backend/parsers/windmill-parser-py-imports/src/lib.rs index 5c8de9790f..f1718c6d94 100644 --- a/backend/parsers/windmill-parser-py-imports/src/lib.rs +++ b/backend/parsers/windmill-parser-py-imports/src/lib.rs @@ -268,6 +268,33 @@ pub fn parse_code_for_imports(code: &str, path: &str) -> anyhow::Result>, +) -> Option> { + let dir = script_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + let sources: HashMap = modules? + .iter() + .filter_map(|(key, module)| { + let stem = key.strip_suffix(".py")?; + let path = if dir.is_empty() { + stem.to_string() + } else { + format!("{dir}/{stem}") + }; + Some((path, module.content.clone())) + }) + .collect(); + (!sources.is_empty()).then_some(sources) +} + #[cfg(not(target_arch = "wasm32"))] pub async fn parse_python_imports( code: &str, @@ -278,6 +305,10 @@ pub async fn parse_python_imports( locked_v: &mut Option, raw_workspace_dependencies_o: &Option, temp_script_refs: &Option>, + // Content of relative imports that ship with the importer rather than being deployed + // (its modules), keyed by the path a relative import resolves to. Wins over both + // `temp_script_refs` and the deployed script, as the module file does at run time. + module_sources: Option<&HashMap>, ) -> error::Result<(Vec, Option)> { let mut compile_error_hint: Option = None; let mut imports = parse_python_imports_inner( @@ -291,6 +322,7 @@ pub async fn parse_python_imports( locked_v, raw_workspace_dependencies_o, temp_script_refs, + module_sources, ) .await? .into_values() @@ -349,6 +381,7 @@ async fn parse_python_imports_inner( locked_v: &mut Option, raw_workspace_dependencies_o: &Option, temp_script_refs: &Option>, + module_sources: Option<&HashMap>, ) -> error::Result> { tracing::debug!("Parsing python imports for path: {}", path); let PythonAnnotations { py310, py311, py312, py313, py314, py315, .. } = @@ -517,10 +550,11 @@ async fn parse_python_imports_inner( for n in nimports.into_iter() { let mut nested = match n { NImport::Relative(rpath) => { - // First try to get content from temp_script_refs cache if available - let code_from_cache = if let Some(hash) = - temp_script_refs.as_ref().and_then(|dt| dt.get(&rpath)) + let code_from_cache = if let Some(content) = + module_sources.and_then(|m| m.get(&rpath)) { + Some(content.clone()) + } else if let Some(hash) = temp_script_refs.as_ref().and_then(|dt| dt.get(&rpath)) { tracing::debug!( "Found relative import '{}' in temp_script_refs with hash '{}'", rpath, @@ -570,6 +604,7 @@ async fn parse_python_imports_inner( locked_v, raw_workspace_dependencies_o, temp_script_refs, + module_sources, ) .await? .into_values() diff --git a/backend/parsers/windmill-parser-py-imports/tests/tests.rs b/backend/parsers/windmill-parser-py-imports/tests/tests.rs index 9086b29d47..6d3d75a239 100644 --- a/backend/parsers/windmill-parser-py-imports/tests/tests.rs +++ b/backend/parsers/windmill-parser-py-imports/tests/tests.rs @@ -29,6 +29,7 @@ def main(): &mut None, &None, &None, + None, ) .await?; // println!("{}", serde_json::to_string(&r)?); @@ -71,6 +72,7 @@ def main(): &mut None, &None, &None, + None, ) .await?; println!("{}", serde_json::to_string(&r)?); @@ -103,6 +105,7 @@ def main(): &mut None, &None, &None, + None, ) .await?; println!("{}", serde_json::to_string(&r)?); diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 6afe7bf30c..471a7251b3 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -48,7 +48,7 @@ alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(b app: id(bigint), workspace_id(char), path(char), summary(char), policy(jsonb), versions(bigint[]), extra_perms(jsonb), draft_only(bool), custom_path(text), labels(text[]) FK: (workspace_id) -> workspace(id) app_bundles: app_version_id(bigint), w_id(char), file_type(char), data(bytes) -app_script: id(bigint), app(bigint), hash(char(64)), lock(text), code(text), code_sha256(char(64)) +app_script: id(bigint), app(bigint), hash(char(64)), lock(text), code(text), code_sha256(char(64)), modules(jsonb) FK: (app) -> app(id) app_version: id(bigint), app_id(bigint), value(json), created_by(char), created_at(ts), raw_app(bool) FK: (app_id) -> app(id) diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 21f916bf94..e6108b1387 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -462,8 +462,9 @@ fn app_embed_route_denied(domain: ScopeDomain, suffix: &str) -> bool { } } -/// App routes a running app uses: its own definition (`apps/get/p/`, further -/// path-scoped by `apps:read:`) and the public app-serving endpoints +/// App routes a running app uses: its own definition (`apps/get/p/`, or +/// `apps/get/lite/` that the viewer runs from, both further path-scoped by +/// `apps:read:`) and the public app-serving endpoints /// (`apps_u/*`: public_app, public_resource, get_data, and the path-taking /// `execute_component` / `download_s3_file`, which re-check `apps:run|read:` /// in their handlers so they stay confined to this app). Everything else in the @@ -477,7 +478,9 @@ fn app_embed_apps_route_allowed(suffix: &str) -> bool { if suffix.starts_with("apps_u/embed_token") { return false; } - suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/") + suffix.starts_with("apps/get/p/") + || suffix.starts_with("apps/get/lite/") + || suffix.starts_with("apps_u/") } /// Routes a guest session is denied: the app-embed allowlist, plus the embed-token diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 19327440b0..6443bf7e2a 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -8547,7 +8547,7 @@ async fn clone_apps( // Clone app scripts with recomputed hashes let app_scripts = sqlx::query!( - "SELECT app, hash, lock, code, code_sha256 + "SELECT id, app, hash, lock, code, code_sha256, modules FROM app_script WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", source_workspace_id @@ -8555,34 +8555,104 @@ async fn clone_apps( .fetch_all(&mut **tx) .await?; + let mut app_script_id_mapping: HashMap = HashMap::new(); for app_script in app_scripts { if let Some(&new_app_id) = app_id_mapping.get(&app_script.app) { - // Recompute hash using app_id, code_sha256, and lock + // Recompute hash using app_id, code_sha256, lock and modules let mut hasher = Sha256::new(); hasher.update(new_app_id.to_be_bytes()); hasher.update(hex::decode(&app_script.code_sha256)?); if let Some(lock) = &app_script.lock { hasher.update(lock.as_bytes()); } + if let Some(modules) = &app_script.modules { + hasher.update(modules.to_string()); + } let new_hash = hex::encode(hasher.finalize()); - sqlx::query!( - "INSERT INTO app_script (app, hash, lock, code, code_sha256) - VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", + let new_id = sqlx::query_scalar!( + "INSERT INTO app_script (app, hash, lock, code, code_sha256, modules) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id + RETURNING id", new_app_id, new_hash, app_script.lock, app_script.code, app_script.code_sha256, + app_script.modules, ) - .execute(&mut **tx) + .fetch_one(&mut **tx) .await?; + app_script_id_mapping.insert(app_script.id, new_id); } } + // A version's lite value is what points each inline script at its `app_script` row (lock and + // modules). Without it the fork serves the full value, whose inline scripts carry no id and + // so run as bare code: no lock, and no modules for a raw app's sibling imports. Every + // version, not just `versions.last()`: the fork's `versions` is rebuilt from all rows above, + // so its current one can be a row the parent's array never listed. + let source_versions: Vec = version_id_mapping.keys().copied().collect(); + let lite_versions = sqlx::query!( + "SELECT id, value AS \"value!: serde_json::Value\" FROM app_version_lite WHERE id = ANY($1)", + &source_versions, + ) + .fetch_all(&mut **tx) + .await?; + let (mut lite_ids, mut lite_values) = (vec![], vec![]); + for lite in lite_versions { + let Some(&new_version_id) = version_id_mapping.get(&lite.id) else { + continue; + }; + let mut value = lite.value; + // Lite inline scripts have no code of their own, so one left without a row cannot run. + if !remap_app_script_ids(&mut value, &app_script_id_mapping) { + continue; + } + if let Some(object) = value.as_object_mut() { + object.insert("version".to_string(), serde_json::json!(new_version_id)); + } + lite_ids.push(new_version_id); + lite_values.push(value); + } + sqlx::query!( + "INSERT INTO app_version_lite (id, value) + SELECT * FROM UNNEST($1::bigint[], $2::jsonb[]) + ON CONFLICT (id) DO NOTHING", + &lite_ids, + &lite_values, + ) + .execute(&mut **tx) + .await?; + Ok(app_id_mapping) } +/// Points every `inlineScript.id` of a cloned lite app value at the fork's copy of that row. +/// False when some id has no copy. +fn remap_app_script_ids(value: &mut serde_json::Value, mapping: &HashMap) -> bool { + match value { + serde_json::Value::Object(object) => { + if let Some(serde_json::Value::Object(script)) = object.get_mut("inlineScript") { + if let Some(old_id) = script.get("id").and_then(serde_json::Value::as_i64) { + let Some(new_id) = mapping.get(&old_id) else { + return false; + }; + script.insert("id".to_string(), serde_json::json!(new_id)); + } + } + object + .values_mut() + .all(|child| remap_app_script_ids(child, mapping)) + } + serde_json::Value::Array(array) => array + .iter_mut() + .all(|child| remap_app_script_ids(child, mapping)), + _ => true, + } +} + async fn clone_raw_apps( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0b0fff1ddf..db7280dbbf 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -15600,6 +15600,12 @@ paths: type: integer tag: type: string + modules: + type: object + nullable: true + description: "Additional modules keyed by relative file path. Honored in preview only: a deployed app runs the modules stored with its runnable." + additionalProperties: + $ref: "#/components/schemas/ScriptModule" required: - content - language diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 20f08e57ad..1dcee82e5c 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -61,7 +61,7 @@ use windmill_common::{ APP_WORKSPACED_ROUTE, }, auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN}, - cache::{self, future::FutureCachedExt}, + cache, db::{DbWithOptAuthed, UserDB}, error::{to_anyhow, Error, JsonResult, Result}, jobs::{ @@ -1502,8 +1502,9 @@ async fn get_public_app_by_secret( /// - `users:read` → `users/whoami` only. /// - `folders:read` → `folders/listnames` only. /// Plus two path-scoped scopes minted per app (see `mint_app_embed_token`): -/// - `apps:read:` → the app's own definition (`apps/get/p/`); no -/// `apps:write`, so management routes are unreachable. +/// - `apps:read:` → the app's own definition (`apps/get/p/` and +/// `apps/get/lite/`); no `apps:write`, so management +/// routes are unreachable. /// - `apps:run:` → run THIS app's components (`execute_component`, which /// re-checks the path); `apps_u/*` public-serving routes. pub const APP_EMBED_SCOPES: [&str; 5] = [ @@ -4674,34 +4675,81 @@ async fn execute_component( // `app_script` id. Its resolved `rawscript/` key must be present // in the policy triggerables below — it must never resolve via the // Viewer default fallback. Without `id` the caller supplies the code - // verbatim; with `id` it selects any `app_script` row by number (no - // app/workspace scoping), so both let a caller run code the publisher - // never pinned for this app. + // verbatim, which lets a caller run code the publisher never pinned + // for this app; with `id` the row must also belong to this app. let is_inline_raw_code = payload.raw_code.is_some(); + // The policy key covers only an inline script's own code, not the lock and modules + // its `app_script` row runs with, so it cannot tell the deployed row from an older + // one (or from id-less code, which runs with neither). Once the deployed version has + // its lite value, inline code runs only through the rows that value points at. + // Checked on every run: a deploy moves it. + if is_inline_raw_code { + let deployed = sqlx::query!( + "SELECT l.id IS NOT NULL as \"has_lite!\", + coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)', + jsonb_build_object('id', $3::bigint)), false) as \"is_deployed_row!\" + FROM app a + LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)] + WHERE a.path = $1 AND a.workspace_id = $2", + path, + &w_id, + payload.id, + ) + .fetch_optional(&db) + .await?; + if deployed.is_some_and(|d| d.has_lite && !d.is_deployed_row) { + return Err(Error::NotAuthorized(format!( + "This runnable is not part of the deployed version of app {path}, reload the app" + ))); + } + } + // Compute the path for the triggerables map: // - flow: `flow/` // - script: `script/` // - inline script: `rawscript/` + let app_script_key: Arc; let path = match &payload { // flow or script: just use the `payload.path`. ExecuteApp { path: Some(path), .. } => path, // inline script: without entry in the `app_script` table. ExecuteApp { raw_code: Some(raw_code), id: None, .. } => &digest(&raw_code.content), // inline script: with an entry in the `app_script` table. + // Scoped to this app: the policy key covers only the entrypoint's code, while the + // row also carries the modules (and lock) the job runs, so another app's row with + // the same entrypoint would run its own modules under this app's identity. ExecuteApp { raw_code: Some(_), id: Some(id), .. } => { - let cache = cache::anon!({ u64 => Arc } in "appscriptpath" <= 10000); - // `id` is unique, cache the result. - &*sqlx::query_scalar!( - "SELECT format('rawscript/%s', code_sha256) as \"path!: String\" - FROM app_script WHERE id = $1 LIMIT 1", - id - ) - .fetch_one(&db) - .map_err(Into::::into) - .map_ok(Arc::new) - .cached(cache, *id as u64) - .await? + lazy_static! { + static ref APP_SCRIPT_KEY_CACHE: cache::Cache<(String, String, i64), Arc> = + cache::Cache::new(10000); + } + let cache_key = (w_id.to_string(), path.to_string(), *id); + app_script_key = match APP_SCRIPT_KEY_CACHE.get(&cache_key) { + Some(key) => key, + None => { + let key = sqlx::query_scalar!( + "SELECT format('rawscript/%s', s.code_sha256) as \"path!: String\" + FROM app_script s JOIN app a ON a.id = s.app + WHERE s.id = $1 AND a.path = $2 AND a.workspace_id = $3", + id, + path, + &w_id, + ) + .fetch_optional(&db) + .await? + .map(Arc::new) + .ok_or_else(|| { + Error::NotAuthorized(format!( + "App script {id} does not belong to app {path}" + )) + })?; + // A row never changes app, so a hit stays valid. + APP_SCRIPT_KEY_CACHE.insert(cache_key, key.clone()); + key + } + }; + &**app_script_key } _ => unreachable!(), }; diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index b8e4ce50e6..f5743782e3 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -2223,14 +2223,22 @@ pub async fn fetch_raw_script_from_app_query( loc: &'static Location<'_>, ) -> error::Result { sqlx::query!( - "SELECT lock, code FROM app_script WHERE id = $1 LIMIT 1", + "SELECT lock, code, modules AS \"modules: serde_json::Value\" + FROM app_script WHERE id = $1 LIMIT 1", id, ) .fetch_optional(db) .await .map_err(Into::into) .and_then(unwrap_or_error(&loc, "Application script", id)) - .map(|r| RawScript { content: r.code, lock: r.lock, meta: None, modules: None }) + .and_then(|r| { + Ok(RawScript { + content: r.code, + lock: r.lock, + meta: None, + modules: r.modules.map(serde_json::from_value).transpose()?, + }) + }) } /// Returns the number of jobs the row already accounted for: non-zero when a worker of the diff --git a/backend/windmill-worker/loader.bun.js b/backend/windmill-worker/loader.bun.js index edfd3375ab..a36e8e136f 100644 --- a/backend/windmill-worker/loader.bun.js +++ b/backend/windmill-worker/loader.bun.js @@ -41,7 +41,10 @@ const p = { imp.path.startsWith("/f/")) && !imp.path.endsWith(".ts") ) { - code = code.replaceAll(imp.path, imp.path + ".ts"); + // Whole quoted specifiers only: `./util` is also a substring of `../util_ws`. + for (const q of ['"', "'"]) { + code = code.replaceAll(q + imp.path + q, q + imp.path + ".ts" + q); + } } } } @@ -55,6 +58,17 @@ const p = { return replaceRelativeImports(code); }); + // Module files (written by write_module_files next to main.ts) need the same `.ts` + // rewrite: an extensionless relative import misses the resolve filter below, and the + // lockfile builder then treats it as an external package named ".". + const filterLoadModule = new RegExp( + `^${cdir}\/(?!node_modules\/)(?!_wm_)(?!main\\.ts$).*\\.ts$` + ); + build.onLoad({ filter: filterLoadModule }, async (args) => { + const code = readFileSync(args.path, "utf8"); + return replaceRelativeImports(code); + }); + // A stalled fetch would otherwise hold the whole build for bun's own 5-minute // default, with nothing naming the script it was waiting on. const RELATIVE_IMPORT_FETCH_TIMEOUT_MS = 120000; @@ -109,18 +123,32 @@ const p = { // Check if the import resolves to a local module file (written by write_module_files). // Only check relative paths — absolute/bare specifiers should fall through to the // remote resolver, matching the Windows loader pattern. - if (args.path.startsWith(".")) { - const localPath = resolve(cdir, args.path); + // Only for local importers (main.ts or a module, both `.ts` in the job dir): a fetched + // workspace script (`.url`) resolves within its own workspace folder, where a module + // file of the same name would shadow its real neighbour. + const importer = args.importer ? resolve(args.importer) : ""; + if ( + args.path.startsWith(".") && + importer.endsWith(".ts") && + importer.startsWith(cdir + "/") + ) { + // From the importing module's own folder: a module may sit in a subfolder (`lib/a.ts`). + const localPath = resolve(dirname(importer), args.path); try { readFileSync(localPath); return { path: localPath }; } catch {} } + const importerRel = args.importer.replace(cdir + "/", ""); + // A module file stands in the job dir for a script in the entrypoint's folder, so its + // own relative imports resolve from that folder's workspace path, not from the job dir. const file_path = args.importer == "./main.ts" || args.importer == resolve("./main.ts") ? current_path - : args.importer.replace(cdir + "/", ""); + : importerRel.endsWith(".ts") + ? join(dirname(current_path), importerRel.replace(/\.ts$/, "")) + : importerRel; const isRelative = !args.path.startsWith("/"); const endExt = args.path.endsWith(".ts") ? "" : ".ts"; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 8e657d1226..201219b674 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1542,6 +1542,26 @@ pub async fn compute_bundle_local_and_remote_path( input_src.push_str(&path); input_src.push_str(&hash.to_string()); } + // Modules are inlined too, and so is every deployed script they import: those are + // reached through the module files, never through the entrypoint's own imports. + let dir = script_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + let mut module_entries = modules.into_iter().flatten().collect_vec(); + module_entries.sort_by(|a, b| a.0.cmp(b.0)); + for (key, module) in module_entries { + let Some(stem) = key.strip_suffix(".ts") else { + continue; + }; + let module_path = format!("{dir}/{stem}"); + for (path, hash) in + collect_transitive_import_versions(db, w_id, &module_path, &module.content).await + { + input_src.push_str(&path); + input_src.push_str(&hash.to_string()); + } + } }; // Keep temp-script-ref (preview) bundles in a distinct cache slot: their diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 4ab3d50df8..e7ddd91531 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -791,7 +791,8 @@ pub async fn handle_python_job( None }; - let (py_version, mut additional_python_paths) = handle_python_deps( + let module_sources = windmill_parser_py_imports::module_sources(&script_path, modules.as_ref()); + let (py_version, mut additional_python_paths) = handle_python_deps_with_modules( job_dir, requirements_o, inner_content, @@ -807,6 +808,7 @@ pub async fn handle_python_job( precomputed_agent_info, annotations.clone(), &temp_script_refs, + module_sources.as_ref(), ) .await?; @@ -1939,6 +1941,7 @@ async fn replace_pip_secret( } } +#[cfg(feature = "private")] pub(crate) async fn handle_python_deps( job_dir: &str, requirements_o: Option<&String>, @@ -1955,6 +1958,45 @@ pub(crate) async fn handle_python_deps( precomputed_agent_info: Option, annotations: PythonAnnotations, temp_script_refs: &Option>, +) -> error::Result<(PyV, Vec)> { + handle_python_deps_with_modules( + job_dir, + requirements_o, + inner_content, + w_id, + script_path, + job_id, + conn, + worker_name, + worker_dir, + mem_peak, + canceled_by, + occupancy_metrics, + precomputed_agent_info, + annotations, + temp_script_refs, + None, + ) + .await +} + +async fn handle_python_deps_with_modules( + job_dir: &str, + requirements_o: Option<&String>, + inner_content: &str, + w_id: &str, + script_path: &str, + job_id: &Uuid, + conn: &Connection, + worker_name: &str, + worker_dir: &str, + mem_peak: &mut i32, + canceled_by: &mut Option, + occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + precomputed_agent_info: Option, + annotations: PythonAnnotations, + temp_script_refs: &Option>, + module_sources: Option<&HashMap>, ) -> error::Result<(PyV, Vec)> { create_dependencies_dir(job_dir).await; @@ -1984,6 +2026,7 @@ pub(crate) async fn handle_python_deps( &mut locked_v, &None, temp_script_refs, + module_sources, )) .await?; diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 6189d39b8d..43bb32f7eb 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs::{create_dir_all, remove_dir_all}; #[cfg(feature = "python")] @@ -2093,8 +2093,10 @@ async fn insert_app_script( code: String, lock: Option, language: Option, + modules: Option>, ) -> Result { let code_sha256 = format!("{:x}", sha2::Sha256::digest(&code)); + let modules = modules.map(|m| serde_json::to_value(m)).transpose()?; let hash = { let mut hasher = sha2::Sha256::new(); hasher.update(app.to_le_bytes()); @@ -2106,14 +2108,18 @@ async fn insert_app_script( relative_imports_bytes(db, Some(&code), &format!("{path}/app"), language).await?, ); } + // Only when present, so a runnable without modules keeps the id it already had. + if let Some(modules) = modules.as_ref() { + hasher.update(modules.to_string()); + } format!("{:x}", hasher.finalize()) }; // Insert the app script if it doesn't exist. sqlx::query_scalar!( r#" - INSERT INTO app_script (app, hash, lock, code, code_sha256) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO app_script (app, hash, lock, code, code_sha256, modules) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id RETURNING id "#, @@ -2121,7 +2127,8 @@ async fn insert_app_script( hash, lock, code, - code_sha256 + code_sha256, + modules ) .fetch_one(db) .await @@ -2294,11 +2301,103 @@ async fn reduce_flow<'c>( Ok(tx) } +/// A raw app's inline runnables, keyed by the path a relative import of one resolves to: +/// `/`, the path the runnable itself runs under. A runnable ships +/// the ones it imports as its modules, so they resolve from the job dir at run time. +#[derive(Default)] +struct RawAppRunnables(HashMap); + +impl RawAppRunnables { + fn from_value(app_path: &str, value: &Value) -> Self { + let mut runnables = HashMap::new(); + if let Some(Value::Object(by_id)) = value.get("runnables") { + for (id, runnable) in by_id { + let Some(script) = runnable.get("inlineScript") else { + continue; + }; + let content = script.get("content").and_then(Value::as_str); + let language = script + .get("language") + .and_then(|l| from_value::(l.clone()).ok()); + if let (Some(content), Some(language)) = (content, language) { + // `main.ts` is the Bun entrypoint's own file in the job dir, so a + // runnable with that id cannot ship as a module next to it. + let ext = module_extension(language); + if ext.is_some() && !(ext == Some("ts") && id == "main") { + runnables.insert( + format!("{app_path}/{id}"), + (id.clone(), language, content.to_string()), + ); + } + } + } + } + Self(runnables) + } + + /// Whether an import of `path` by `language` code resolves to a runnable shipped as a + /// module, rather than to the workspace script at that path. + fn resolves(&self, path: &str, language: ScriptLang) -> bool { + self.0.get(path).is_some_and(|(_, sibling_language, _)| { + module_extension(*sibling_language) == module_extension(language) + }) + } + + /// The runnables `content` imports, directly or through one another. + fn modules_of( + &self, + app_path: &str, + content: &str, + language: ScriptLang, + ) -> Option> { + let ext = module_extension(language)?; + let mut queue = + extract_relative_imports(content, &format!("{app_path}/app"), &Some(language)) + .unwrap_or_default(); + let mut visited = HashSet::new(); + let mut modules = BTreeMap::new(); + while let Some(path) = queue.pop() { + if !visited.insert(path.clone()) { + continue; + } + let Some((id, sibling_language, sibling_content)) = self.0.get(&path) else { + continue; + }; + if module_extension(*sibling_language) != Some(ext) { + continue; + } + queue.extend( + extract_relative_imports(sibling_content, &path, &Some(*sibling_language)) + .unwrap_or_default(), + ); + modules.insert( + format!("{id}.{ext}"), + ScriptModule { + content: sibling_content.clone(), + language: *sibling_language, + lock: None, + }, + ); + } + (!modules.is_empty()).then_some(modules) + } +} + +/// The languages whose executors resolve a relative import against a module file. +fn module_extension(language: ScriptLang) -> Option<&'static str> { + match language { + ScriptLang::Bun | ScriptLang::Bunnative => Some("ts"), + ScriptLang::Python3 => Some("py"), + _ => None, + } +} + async fn reduce_app( db: &sqlx::Pool, path: &str, value: &mut Value, app: i64, + raw_app_runnables: &RawAppRunnables, ) -> Result<()> { match value { Value::Object(object) => { @@ -2319,26 +2418,20 @@ async fn reduce_app( Value::String(s) => Some(s), _ => None, }); - let id = insert_app_script( - db, - path, - app, - code, - lock, - language.map(|v| from_value(v).ok()).flatten(), - ) - .await?; + let language: Option = language.and_then(|v| from_value(v).ok()); + let modules = language.and_then(|l| raw_app_runnables.modules_of(path, &code, l)); + let id = insert_app_script(db, path, app, code, lock, language, modules).await?; // insert the `id` into the `script` object: script.insert("id".to_string(), json!(id.0)); } else { for (_, value) in object { - Box::pin(reduce_app(db, path, value, app)).await?; + Box::pin(reduce_app(db, path, value, app, raw_app_runnables)).await?; } } } Value::Array(array) => { for value in array { - Box::pin(reduce_app(db, path, value, app)).await?; + Box::pin(reduce_app(db, path, value, app, raw_app_runnables)).await?; } } _ => {} @@ -2383,6 +2476,7 @@ async fn lock_modules_app( raw_workspace_dependencies_o: &Option, triggered_by_relative_import: bool, temp_script_refs: &Option>, + raw_app_runnables: &RawAppRunnables, ) -> Result { match value { Value::Object(mut m) => { @@ -2417,15 +2511,29 @@ async fn lock_modules_app( .to_string(); let mut logs = "".to_string(); + // An import satisfied by another runnable of the app is not a + // dependency on the workspace script at that path. let referenced_paths = extract_referenced_paths( &content, &format!("{job_path}/app"), Some(language), - ); + ) + .map(|paths| { + paths + .into_iter() + .filter(|p| !raw_app_runnables.resolves(p, language)) + .collect_vec() + }); + // The lock covers what the imported runnables import, and nothing + // records whether one of them changed, so it is never reused. + let modules = + raw_app_runnables.modules_of(job_path, &content, language); + let modules = modules.map(|m| m.into_iter().collect::>()); if let Some((l, id)) = locks_to_reload .as_ref() .zip(container_id.as_ref()) + .filter(|_| modules.is_none()) // TODO: Remove fallback .and_then(|e| { if *WMDEBUG_NO_COMPONENTS_TO_RELOCK { @@ -2447,9 +2555,9 @@ async fn lock_modules_app( .await?; return Ok(Value::Object(m.clone())); } - } else if v - .get("lock") - .is_some_and(|x| !x.as_str().unwrap().trim().is_empty()) + } else if modules.is_none() + && v.get("lock") + .is_some_and(|x| !x.as_str().unwrap().trim().is_empty()) { if skip_creating_new_lock(&language, &content) && (MIN_VERSION_SUPPORTS_DEBOUNCING_V2.met().await @@ -2486,7 +2594,7 @@ async fn lock_modules_app( base_internal_url, token, &format!("{}/app", job.runnable_path()), - None, + modules.as_ref(), occupancy_metrics, &None, existing_lock, @@ -2573,6 +2681,7 @@ async fn lock_modules_app( raw_workspace_dependencies_o, triggered_by_relative_import, temp_script_refs, + raw_app_runnables, ) .await?, ); @@ -2602,6 +2711,7 @@ async fn lock_modules_app( raw_workspace_dependencies_o, triggered_by_relative_import, temp_script_refs, + raw_app_runnables, ) .await?, ); @@ -2716,6 +2826,11 @@ pub async fn handle_app_dependency_job( // TODO: Use transaction for entire segment? if let Some((app_id, value, is_raw_app)) = record { + let raw_app_runnables = if is_raw_app { + RawAppRunnables::from_value(&job_path, &value) + } else { + RawAppRunnables::default() + }; let value = lock_modules_app( value, &job, @@ -2735,6 +2850,7 @@ pub async fn handle_app_dependency_job( &raw_workspace_dependencies_o, triggered_by_relative_import, &temp_script_refs, + &raw_app_runnables, ) .await?; @@ -2747,7 +2863,7 @@ pub async fn handle_app_dependency_job( // Compute a lite version of the app value (w/ `inlineScript.{lock,code}`). let mut value_lite = value.clone(); - reduce_app(db, &job_path, &mut value_lite, app_id).await?; + reduce_app(db, &job_path, &mut value_lite, app_id, &raw_app_runnables).await?; if let Value::Object(object) = &mut value_lite { object.insert("version".to_string(), json!(id)); object.remove("files"); @@ -3311,6 +3427,7 @@ async fn capture_dependency_job( &mut locked_v, raw_workspace_dependencies_o, temp_script_refs, + windmill_parser_py_imports::module_sources(script_path, modules).as_ref(), ) .await? .0 @@ -3436,6 +3553,11 @@ async fn capture_dependency_job( // TODO: move inside gen_bun_lockfile if !wd_exist { write_file(job_dir, "main.ts", job_raw_code)?; + // The lock has to cover what the modules import, and the loader resolves a + // relative import against these files before asking for a deployed script. + if let Some(modules) = modules { + crate::worker::write_module_files(job_dir, modules, None).await?; + } } if let Some(lock) = gen_bun_lockfile( mem_peak, @@ -3457,10 +3579,9 @@ async fn capture_dependency_job( ) .await? { - // Nothing here writes the module files, so a bundle built now resolves a - // multi-file script's relative imports remotely rather than from its - // modules; caching that under a key naming them would serve the wrong - // code to every run. Leave it to the first run instead. + // A runnable with modules bundles on its first run instead: a multi-file + // script's per-module lock pass also lands here, once per module, and would + // prebundle each module as if it were an entrypoint no run ever asks for. if !wd_exist && modules.map_or(true, |m| m.is_empty()) { crate::bun_executor::prebundle_bun_script( job_raw_code, @@ -3621,3 +3742,32 @@ async fn add_lock_header( Ok(()) } + +#[cfg(test)] +mod raw_app_runnables_tests { + use super::*; + + #[test] + fn modules_follow_imports_between_runnables_of_one_language() { + let value = json!({ "runnables": { + "main": { "inlineScript": { "language": "bun", "content": "import { a } from './a'" } }, + "a": { "inlineScript": { "language": "bun", "content": "import { b } from './b.ts'" } }, + "b": { "inlineScript": { "language": "bun", "content": "export const b = 1" } }, + "unused": { "inlineScript": { "language": "bun", "content": "export const u = 1" } }, + "py": { "inlineScript": { "language": "python3", "content": "x = 1" } }, + }}); + let runnables = RawAppRunnables::from_value("u/admin/app", &value); + + let modules = runnables + .modules_of( + "u/admin/app", + "import { a } from './a'\nimport { p } from './py'", + ScriptLang::Bun, + ) + .unwrap(); + assert_eq!(modules.keys().collect_vec(), vec!["a.ts", "b.ts"]); + assert!(runnables + .modules_of("u/admin/app", "export const x = 1", ScriptLang::Bun) + .is_none()); + } +} diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index fa8b8fceb8..fbeba2914f 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -1181,6 +1181,7 @@ async function dev(opts: DevOptions, appFolder?: string) { runnableId, args, appTempRefs, + previewModules(runnables, runnableId), ); log.info(colors.gray(`[backend] Job started: ${uuid}`)); @@ -1228,6 +1229,7 @@ async function dev(opts: DevOptions, appFolder?: string) { runnable_id, v, appTempRefs, + previewModules(runnables, runnable_id), ); log.info(colors.gray(`[backendAsync] Job started: ${uuid}`)); @@ -1813,6 +1815,55 @@ async function loadRunnables(): Promise> { } } +function moduleExtension(language: string | undefined): string | undefined { + if (language === "bun" || language === "bunnative") return "ts"; + if (language === "python3") return "py"; + return undefined; +} + +/** Ids of the sibling runnables `content` imports as `./` (TS) or `.` (Python). */ +function siblingImports(content: string, ext: string): string[] { + const pattern = ext === "ts" + ? /(?:from|import)\s*['"]\.\/([^'"/]+?)(?:\.ts)?['"]/g + : /^\s*from\s+\.([A-Za-z_]\w*)\s+import\b/gm; + return Array.from(content.matchAll(pattern), (m) => m[1]); +} + +/** + * The app's other inline runnables that `runnableId` imports, directly or through one + * another, as the modules its preview ships with so those imports resolve to the local + * files. Deploy stores the same set on the runnable. + */ +function previewModules( + runnables: Record, + runnableId: string, +): Record | undefined { + const inlineScriptOf = (r: Runnable | undefined) => + r && "inlineScript" in r ? r.inlineScript : undefined; + const own = inlineScriptOf(runnables[runnableId]); + const ext = moduleExtension(own?.language); + if (!own || !ext) return undefined; + const modules: Record = {}; + const queue = siblingImports(own.content, ext); + while (queue.length > 0) { + const id = queue.pop()!; + const key = `${id}.${ext}`; + const inlineScript = inlineScriptOf(runnables[id]); + // `main.ts` is the Bun entrypoint's own file, so no module may take that name. The + // runnable itself is kept when a cycle reaches it: the entrypoint is not under its id. + if (key in modules || key === "main.ts") continue; + if (!inlineScript || moduleExtension(inlineScript.language) !== ext) { + continue; + } + modules[key] = { + content: inlineScript.content, + language: inlineScript.language, + }; + queue.push(...siblingImports(inlineScript.content, ext)); + } + return Object.keys(modules).length > 0 ? modules : undefined; +} + async function executeRunnable( runnable: Runnable, workspace: string, @@ -1820,6 +1871,7 @@ async function executeRunnable( runnableId: string, args: any, tempScriptRefs?: Record, + modules?: Record, ): Promise { const requestBody: any = { component: runnableId, @@ -1852,12 +1904,18 @@ async function executeRunnable( if (inlineScript.id !== undefined) { requestBody.id = inlineScript.id; } + // A lock is not regenerated when only an imported runnable is edited, so with + // modules the preview resolves dependencies afresh. + const previewMods = inlineScript.id === undefined ? modules : undefined; requestBody.raw_code = { content: inlineScript.id === undefined ? inlineScript.content : "", language: inlineScript.language ?? "", path: `${appPath}/${runnableId}`, - lock: inlineScript.id === undefined ? inlineScript.lock : undefined, + lock: inlineScript.id === undefined && !previewMods + ? inlineScript.lock + : undefined, cache_ttl: inlineScript.cache_ttl, + ...(previewMods ? { modules: previewMods } : {}), }; if (inlineScript.id === undefined && tempScriptRefs) { requestBody.temp_script_refs = tempScriptRefs; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 6b3c2822ae..4a7ef9bbbe 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6875,6 +6875,10 @@ def main(user_id: str): return user \`\`\` +#### Sharing code between runnables + +An inline runnable can import another inline runnable of the same app by its id, as a relative import: \`import { getDb } from './shared'\` (TypeScript, Bun) or \`from .shared import get_db\` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no \`main\`; the frontend just never calls it. A TypeScript runnable whose id is \`main\` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id. + #### The \`wmill\` client is already authenticated An inline runnable runs as an ordinary Windmill job. \`import * as wmill from 'windmill-client'\` (TypeScript) and \`import wmill\` (Python) are already pointed at this instance and this workspace — there is nothing to configure. diff --git a/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts b/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts index a59f10b66b..5f11ac571f 100644 --- a/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts +++ b/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts @@ -1,4 +1,4 @@ -import { AppService, type ExecuteComponentData } from '$lib/gen' +import { AppService, type ExecuteComponentData, type ScriptModule } from '$lib/gen' import { defaultIfEmptyString } from '$lib/utils' import { isRunnableByName, isRunnableByPath, type Runnable } from '../../inputType' import type { InlineScript } from '../../sharedTypes' @@ -12,7 +12,8 @@ export async function executeRunnable( id: string, requestBody: ExecuteComponentData['requestBody'], inlineScriptOverride?: InlineScript, - queryParams?: Record + queryParams?: Record, + modules?: Record ) { let appPath = defaultIfEmptyString(path, `u/${username ?? 'unknown'}/newapp`) if (isRunnableByName(runnable)) { @@ -24,13 +25,17 @@ export async function executeRunnable( if (inlineScript.id !== undefined) { requestBody['id'] = inlineScript.id } + // A lock is not cleared when only an imported runnable is edited, so with + // modules the preview resolves dependencies afresh. + const previewModules = inlineScript.id === undefined ? modules : undefined requestBody['raw_code'] = { content: inlineScript.id === undefined ? inlineScript.content : '', language: inlineScript.language ?? '', path: appPath + '/' + id, - lock: inlineScript.id === undefined ? inlineScript.lock : undefined, + lock: inlineScript.id === undefined && !previewModules ? inlineScript.lock : undefined, cache_ttl: inlineScript.cache_ttl, - ...(inlineScript.tag ? { tag: inlineScript.tag } : {}) + ...(inlineScript.tag ? { tag: inlineScript.tag } : {}), + ...(previewModules ? { modules: previewModules } : {}) } } } else if (isRunnableByPath(runnable)) { diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index 3384ad3e87..fe915e7ede 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -123,8 +123,8 @@ // Viewer side — used for the inline renderings (raw, and unsandboxed low-code); // the sandboxed low-code case loads inside the opaque /app_embed iframe instead. - // getAppByPath returns bundle_secret + runnables for raw apps, which - // PublicApp -> RawAppPreview needs. + // getAppLiteByPath returns the runnables (with their ids) that + // PublicApp -> RawAppPreview needs for raw apps. async function loadApp() { // Kept local and handed to PublicApp rather than written to `userStore`: this is the // membership in `workspace`, and a session preview tab shows a workspace the rest of @@ -134,8 +134,10 @@ // populated `userStore` for the workspace the page *is* on. appUser = await getUserExt(workspace) try { - const loaded: any = await AppService.getAppByPath({ workspace, path }) - // Raw apps need the bundle secret to load their bundle. getAppByPath + // The lite value: its inline scripts carry the ids of their deployed rows, the only + // code a deployed app runs (with its lock, and a raw app's imported runnables). + const loaded: any = await AppService.getAppLiteByPath({ workspace, path }) + // Raw apps need the bundle secret to load their bundle. getAppLiteByPath // doesn't compute it (unlike the public handlers), so fetch it here — the // same call the previous raw viewer used — and hand it to PublicApp -> // RawAppPreview via bundle_secret. diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index 31c0623e49..487da15f1f 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -5,7 +5,7 @@ import type { JobById } from '../apps/types' import { JobService } from '$lib/gen' import type { Runnable } from './rawAppPolicy' - import { RAW_APP_PREVIEW_RELAY } from './utils' + import { RAW_APP_PREVIEW_RELAY, rawAppPreviewModules } from './utils' import { undefinedIfEmpty } from '$lib/utils' interface Props { @@ -162,7 +162,9 @@ on_behalf_of: editor ? previewOnBehalfOf : undefined, viewer_data_access: editor ? runnable?.viewer_data_access || undefined : undefined }, - undefined + undefined, + undefined, + editor ? rawAppPreviewModules(runnables, runnable_id) : undefined ) launchedJobs.add(uuid) let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid } diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte index 810beda674..d60c2262ec 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte @@ -31,6 +31,7 @@ import { userStore } from '$lib/stores' import { isHubFlowPath } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { rawAppPreviewModules } from './utils' import { useOperatingUser, useOperatingWorkspace @@ -51,6 +52,8 @@ runnable: Runnable id: string appPath: string + /** All of the app's runnables, which a test run ships as modules for `./` imports */ + runnables?: Record /** Identity the test runs as instead of the user (see `appPreviewOnBehalfOf`). */ previewOnBehalfOf?: string lastDeployedCode?: string | undefined @@ -70,6 +73,7 @@ runnable = $bindable(), id, appPath, + runnables, previewOnBehalfOf, onSelectionChange }: Props = $props() @@ -193,7 +197,7 @@ undefined, undefined, undefined, - undefined, + runnables ? rawAppPreviewModules(runnables, id) : undefined, undefined, undefined, { onBehalfOf: previewOnBehalfOf, viewerDataAccess: runnable.viewer_data_access } @@ -217,13 +221,7 @@ // The deployed app runs a path runnable as the app's identity, which a run // by path cannot ask for: preview the deployed definition instead. const flow = await FlowService.getFlowByPath({ workspace: opWs!, path: runnable.path }) - await jobLoader.runFlowPreview( - args, - flow, - undefined, - runnable.path, - previewOnBehalfOf - ) + await jobLoader.runFlowPreview(args, flow, undefined, runnable.path, previewOnBehalfOf) } else { await jobLoader.runFlowByPath(runnable.path, args) } diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte index 6143d367d2..5a2399457f 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte @@ -40,6 +40,7 @@ {#key selectedRunnable} { createScriptFromInlineScript(selectedRunnable ?? '', e.detail, opWs ?? '', appPath) diff --git a/frontend/src/lib/components/raw_apps/utils.test.ts b/frontend/src/lib/components/raw_apps/utils.test.ts index 74a5c4c097..36b187b6a9 100644 --- a/frontend/src/lib/components/raw_apps/utils.test.ts +++ b/frontend/src/lib/components/raw_apps/utils.test.ts @@ -7,10 +7,37 @@ import { formatRuntimeLogsForChat, genWmillTs, normalizeRawAppRuntimeLogs, + rawAppPreviewModules, stripRawAppDiffNoise, type Runnable } from './utils' +describe('rawAppPreviewModules', () => { + const inline = (language: 'bun' | 'python3', content: string) => + ({ type: 'inline', inlineScript: { language, content } }) as unknown as Runnable + + it('ships only the same-language runnables reached through imports', () => { + const runnables = { + get: inline('bun', "import { a } from './a'\nimport { p } from './py'"), + a: inline('bun', 'import { b } from "./b.ts"'), + b: inline('bun', "import { g } from './get'\nexport const b = 1"), + unused: inline('bun', 'export const u = 1'), + main: inline('bun', 'export const m = 1'), + py: inline('python3', 'from .helpers import x') + } + // `get` itself ships too once the cycle through `b` reaches it, as deploy does. + expect(Object.keys(rawAppPreviewModules(runnables, 'get') ?? {}).sort()).toEqual([ + 'a.ts', + 'b.ts', + 'get.ts' + ]) + expect(rawAppPreviewModules(runnables, 'unused')).toBeUndefined() + expect( + rawAppPreviewModules({ ...runnables, helpers: inline('python3', 'x = 1') }, 'py') + ).toEqual({ 'helpers.py': { language: 'python3', content: 'x = 1' } }) + }) +}) + const flowSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index 12be87dfd9..f87f014d8e 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -1,4 +1,4 @@ -import type { ScriptLang } from '../../gen/types.gen' +import type { ScriptLang, ScriptModule } from '../../gen/types.gen' import type { Schema } from '../../common' import { schemaToTsType } from '../../schema' import { isRunnableByName, isRunnableByPath, type RunnableWithFields } from '../apps/inputType' @@ -18,6 +18,49 @@ export type RawApp = { files: string[] } +function moduleExtension(language: ScriptLang | undefined): string | undefined { + if (language === 'bun' || language === 'bunnative') return 'ts' + if (language === 'python3') return 'py' + return undefined +} + +/** Ids of the sibling runnables `content` imports as `./` (TS) or `.` (Python). */ +function siblingImports(content: string, ext: string): string[] { + const pattern = + ext === 'ts' + ? /(?:from|import)\s*['"]\.\/([^'"/]+?)(?:\.ts)?['"]/g + : /^\s*from\s+\.([A-Za-z_]\w*)\s+import\b/gm + return Array.from(content.matchAll(pattern), (m) => m[1]) +} + +/** + * The app's other inline runnables that runnable `id` imports, directly or through one + * another, as the modules its preview ships with so those imports resolve to the + * editor's code. Deploy stores the same set on the runnable. + */ +export function rawAppPreviewModules( + runnables: Record, + id: string +): Record | undefined { + const own = runnables[id]?.inlineScript + const ext = moduleExtension(own?.language) + if (!own || !ext) return undefined + const modules: Record = {} + const queue = siblingImports(own.content, ext) + while (queue.length > 0) { + const siblingId = queue.pop()! + const key = `${siblingId}.${ext}` + const inlineScript = runnables[siblingId]?.inlineScript + // `main.ts` is the Bun entrypoint's own file, so no module may take that name. The + // runnable itself is kept when a cycle reaches it: the entrypoint is not under its id. + if (key in modules || key === 'main.ts') continue + if (!inlineScript || moduleExtension(inlineScript.language) !== ext) continue + modules[key] = { content: inlineScript.content, language: inlineScript.language } + queue.push(...siblingImports(inlineScript.content, ext)) + } + return Object.keys(modules).length > 0 ? modules : undefined +} + // Server-managed columns the deployed app row (getAppByPath) carries but the // editor's current value never does — leaving them in renders as spurious diff. const RAW_APP_DEPLOYED_METADATA_KEYS = [ diff --git a/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte index 9b3a9c1e41..e2866f5674 100644 --- a/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte @@ -58,7 +58,8 @@ console.warn('Anonymous user') } try { - app = await AppService.getAppByPath({ workspace, path }) + // The lite value, whose inline scripts carry their deployed rows' ids: see InWorkspaceAppViewer. + app = await AppService.getAppLiteByPath({ workspace, path }) noPermission = false notExists = false } catch (e: any) { diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 507c3ed474..9aee298999 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1099,6 +1099,10 @@ def main(user_id: str): return user \`\`\` +#### Sharing code between runnables + +An inline runnable can import another inline runnable of the same app by its id, as a relative import: \`import { getDb } from './shared'\` (TypeScript, Bun) or \`from .shared import get_db\` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no \`main\`; the frontend just never calls it. A TypeScript runnable whose id is \`main\` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id. + #### The \`wmill\` client is already authenticated An inline runnable runs as an ordinary Windmill job. \`import * as wmill from 'windmill-client'\` (TypeScript) and \`import wmill\` (Python) are already pointed at this instance and this workspace — there is nothing to configure. diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 630d650ed2..a43ea23b38 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -432,6 +432,10 @@ def main(user_id: str): return user ``` +#### Sharing code between runnables + +An inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id. + #### The `wmill` client is already authenticated An inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 4350de4085..fbaca028c0 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -193,6 +193,10 @@ def main(user_id: str): return user ``` +#### Sharing code between runnables + +An inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id. + #### The `wmill` client is already authenticated An inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure. From b563f5cb5121c431f525c2e0266994c6d655185b Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 15 Oct 2026 14:01:33 +0200 Subject: [PATCH 36/49] fix(cli): include script modules in wmill dev previews (#11787) * fix(cli): include script modules in wmill dev previews * fix(cli): keep dotted script paths whole on wmill dev edits --- cli/src/commands/dev/dev.ts | 70 +++++++++++++++++++++---------------- cli/test/dev_server.test.ts | 38 ++++++++++++++++++-- 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index 00a6f72f0e..562c38233e 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -30,8 +30,8 @@ import type { ScriptModule } from "../../../gen/types.gen.ts"; import { DBT_DESCRIPTOR_NAME, DBT_MODULE_SUFFIX, + getModuleFolderSuffix, getScriptBasePathFromModulePath, - isDbtModulePath, } from "../../utils/resource_folders.ts"; import { inferContentTypeFromFilePath } from "../../utils/script_common.ts"; import { OpenFlow } from "../../../gen/types.gen.ts"; @@ -148,6 +148,19 @@ export async function listWorkspacePaths(): Promise { items.push({ path: stripFolderSuffix(childRel, APP_SUFFIXES), kind: "raw_app" }); continue; } + // A script's own bundle, whose files are its modules rather than + // scripts. In folder layout the script itself is its `script.*`. + if (entry.name.endsWith(getModuleFolderSuffix())) { + const files = await readdir(childAbs).catch(() => [] as string[]); + if (files.some((f) => f.startsWith("script.") && hasScriptExt(f))) { + items.push({ + path: childRel.slice(0, -getModuleFolderSuffix().length), + kind: "script", + _metaPath: path.join(childAbs, "script.yaml"), + }); + } + continue; + } // A dbt script IS the project directory: its descriptor is optional, so // there may be no file here to recognize it by. Not descended into // either — the project's own `.sql` models would otherwise each be @@ -307,30 +320,25 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { const flowMetadataFile = getMetadataFileName("flow", "yaml"); async function loadPaths(pathsToLoad: string[]) { - // A change ANYWHERE inside a dbt project is a change to that script: the - // bundle is the project, so the whole thing is re-read and rebroadcast. - // Treating the file as a script of its own would drop the edit — a bare - // `.sql` has no language to infer, and a `.yml` or `.csv` is filtered out - // below — leaving the browser previewing the snapshot taken at startup. + // Module edits belong to the parent script's bundle, even when the file + // has no standalone script extension, as with dbt's `.yml` and `.csv`. const rest: string[] = []; - const dbtProjects = new Set(); + const scriptsWithModules = new Set(); for (const raw of pathsToLoad) { const rel = (await realpath(raw).catch(() => raw)) .replace(base + SEP, "") .replaceAll("\\", "/"); - const wmPath = isDbtModulePath(rel) - ? getScriptBasePathFromModulePath(rel) - : undefined; + const wmPath = getScriptBasePathFromModulePath(rel); // Every project in the batch, and each only once: a save-all or a // `git checkout` touches many files at once, and returning on the first // would drop both the other projects and whatever else changed with them. - if (wmPath) dbtProjects.add(wmPath); + if (wmPath) scriptsWithModules.add(wmPath); else rest.push(raw); } - for (const wmPath of dbtProjects) { + for (const wmPath of scriptsWithModules) { const edit = await loadWmPath(wmPath); if (edit) { - log.info("Updated " + wmPath + " (dbt project)"); + log.info("Updated " + wmPath + " (script modules)"); broadcastChanges(edit); } } @@ -399,8 +407,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { log.info("Updated " + wmFlowPath); broadcastChanges(currentLastEdit); } else if (typ == "script") { - const splitted = cpath.split("."); - const wmPath = splitted[0]; + const wmPath = removeExtensionToPath(cpath); const content = await readTextFile(cpath); const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs); const typed = @@ -419,6 +426,9 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { tag: typed?.tag, lock: typed?.lock, temp_script_refs: tempScriptRefs, + modules: await readModulesFromDisk( + wmPath + getModuleFolderSuffix(lang), opts.defaultTs, lang === "dbt", lang === "dbt" + ), }; log.info("Updated " + wmPath); broadcastChanges(currentLastEdit); @@ -433,9 +443,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { tag?: string; lock?: string; temp_script_refs?: Record; - /** The bundle the dev page forwards to a preview run. A dbt project cannot - * run without it: the worker looks for `dbt_project.yml` in the bundle and - * refuses the job when it is not there. */ + /** Module bundle forwarded by the dev page to preview runs. */ modules?: Record; }; @@ -494,9 +502,14 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { // Not a flow, try as script } - // Try as script - for (const ext of exts) { - const filePath = wmPath + ext; + // Try as script: flat `.`, then folder layout `__mod/script.` + const candidates = [ + ...exts.map((ext) => ({ ext, filePath: wmPath + ext, folderLayout: false })), + ...exts + .filter((ext) => !ext.includes("/")) + .map((ext) => ({ ext, filePath: wmPath + getModuleFolderSuffix() + "/script" + ext, folderLayout: true })), + ]; + for (const { ext, filePath, folderLayout } of candidates) { try { // A dbt project's descriptor is optional, so what says "this is a dbt // script" is the project beside it. Requiring the descriptor to exist @@ -516,7 +529,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { } const content = isAbsentDbtDescriptor ? "" : await readTextFile(filePath); const lang = inferContentTypeFromFilePath(filePath, opts.defaultTs); - const typed = (await parseMetadataFile(removeExtensionToPath(filePath), undefined))?.payload; + const typed = (await parseMetadataFile(wmPath, undefined))?.payload; const edit: LastEditScript = { type: "script", content, @@ -528,15 +541,12 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { // Read VERBATIM for dbt, the way push does: the project's `.sql` and // `.yml` files are dbt's, and inferring a language for each would // drop the ones that are not Windmill scripts. - modules: + modules: await readModulesFromDisk( + wmPath + getModuleFolderSuffix(lang), + opts.defaultTs, + folderLayout || lang === "dbt", lang === "dbt" - ? await readModulesFromDisk( - wmPath + DBT_MODULE_SUFFIX, - opts.defaultTs, - true, - true - ) - : undefined, + ), }; currentLastEdit = edit; return edit; diff --git a/cli/test/dev_server.test.ts b/cli/test/dev_server.test.ts index 558fea0241..b4f630b3fa 100644 --- a/cli/test/dev_server.test.ts +++ b/cli/test/dev_server.test.ts @@ -82,6 +82,9 @@ test( `summary: "test"\ndescription: ""\nlock: ""\nschema:\n $schema: "https://json-schema.org/draft/2020-12/schema"\n type: object\n properties: {}\n required: []\n`, "utf-8", ); + const moduleDir = join(scriptDir, "hello__mod"); + await mkdir(moduleDir); + await writeFile(join(moduleDir, "query.gql"), "query { __typename }\n"); // Push the script so the workspace has content const pushResult = await backend.runCLICommand( @@ -94,6 +97,13 @@ test( } expect(pushResult.code).toEqual(0); + // Folder layout: the script's content is its own module folder's `script.*` + const folderModuleDir = join(scriptDir, "folder__mod"); + await mkdir(folderModuleDir); + await writeFile(join(folderModuleDir, "script.ts"), 'export function main() { return "folder"; }\n'); + await writeFile(join(folderModuleDir, "script.yaml"), "summary: \"\"\ndescription: \"\"\nlock: \"\"\n"); + await writeFile(join(folderModuleDir, "query.gql"), "query { __typename }\n"); + // Build the CLI command for `wmill dev` const cliMainPath = getCLIMainPath(); const args = [ @@ -178,14 +188,13 @@ test( expect(ws.readyState).toEqual(WebSocket.OPEN); - // Set up a promise to receive the next WebSocket message const isWindows = process.platform === "win32"; - const messagePromise = new Promise((resolve, reject) => { + const nextMessage = () => new Promise((resolve, reject) => { const timeout = setTimeout( () => reject(new Error("WebSocket message timeout")), isWindows ? 30000 : 10000, ); - ws!.on("message", (data) => { + ws!.once("message", (data) => { clearTimeout(timeout); try { resolve(JSON.parse(data.toString())); @@ -195,6 +204,21 @@ test( }); }); + const initialMessage = nextMessage(); + ws.send(JSON.stringify({ type: "loadWmPath", path: "f/test/hello" })); + expect((await initialMessage).modules["query.gql"]).toEqual({ + content: "query { __typename }\n", + language: "graphql", + }); + + const folderMessage = nextMessage(); + ws.send(JSON.stringify({ type: "loadWmPath", path: "f/test/folder" })); + const folder = await folderMessage; + expect(folder.content).toContain("folder"); + expect(Object.keys(folder.modules)).toEqual(["query.gql"]); + + const messagePromise = nextMessage(); + // Modify the script file on disk // Windows fs.watch() needs more time to initialize with recursive: true await new Promise((r) => setTimeout(r, isWindows ? 2000 : 300)); @@ -212,6 +236,14 @@ test( expect(message.content).toContain("modified"); expect(message.path).toContain("f/test/hello"); expect(message.language).toBeTruthy(); + expect(message.modules["query.gql"].content).toEqual("query { __typename }\n"); + + const moduleMessage = nextMessage(); + await writeFile(join(moduleDir, "query.gql"), "query Updated { __typename }\n"); + const updated = await moduleMessage; + expect(updated.path).toEqual("f/test/hello"); + expect(updated.content).toContain("modified"); + expect(updated.modules["query.gql"].content).toEqual("query Updated { __typename }\n"); } finally { if (ws) { ws.close(); From ab8983543b0641c96a8b4a2cbd829cc4040e9f41 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 15 Oct 2026 14:02:37 +0200 Subject: [PATCH 37/49] fix: send Azure Foundry gpt-5.5+ models to the Responses API (#11788) * fix: send Azure Foundry gpt-5.5+ models to the Responses API Co-Authored-By: Claude Opus 5.5 * fix: name Azure AI Foundry in the reasoning summary notice Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-ai/src/providers/mod.rs | 17 +++++++++++++++++ .../copilot/chat/AIChatManager.svelte.ts | 6 +++--- .../components/copilot/chat/chatLoop.test.ts | 4 +++- .../src/lib/components/copilot/chat/chatLoop.ts | 4 ++-- frontend/src/lib/components/copilot/lib.ts | 8 ++++---- .../copilot/reasoningRegistry.test.ts | 7 +++++++ .../lib/components/copilot/reasoningRegistry.ts | 13 +++++++++++++ 7 files changed, 49 insertions(+), 10 deletions(-) diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 7dcddc93a3..82dceaa02f 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -30,6 +30,11 @@ pub fn effective_reasoning_effort<'a>(model: &str, effort: Option<&'a str>) -> O } } +/// Whether Chat Completions refuses function tools while this model reasons. +pub(crate) fn completions_rejects_tools_with_reasoning(model: &str) -> bool { + reasoning_rule(model).is_some_and(|rule| rule.completions_tools_need_off) +} + /// Whether a request with function tools must send the off sentinel on Chat Completions: /// the model refuses tools there while it reasons, and does accept being turned off. pub(crate) fn completions_tools_need_reasoning_off(model: &str) -> bool { @@ -208,6 +213,12 @@ pub fn create_query_builder( AIProvider::AzureFoundry if AIProvider::is_anthropic_model(model) => Box::new( AnthropicQueryBuilder::new(AIProvider::AzureFoundry, AIPlatform::Standard), ), + // Foundry serves its OpenAI models on `/openai/v1/responses` too. `/chat/completions` + // takes function tools from these only with reasoning off, which gpt-6.1-sol and + // gpt-6-astra cannot do at all. + AIProvider::AzureFoundry if completions_rejects_tools_with_reasoning(model) => { + Box::new(OpenAIQueryBuilder::new(AIProvider::AzureFoundry)) + } // Only for forwarding through the API proxy, which is bearer auth on `/` as // TypeSafe and Cloudflare expect. Neither serves a chat route for its decision models, and // `run_agent` refuses both before a chat request could be built. @@ -408,6 +419,12 @@ mod parity_tests { "claude-sonnet-5", "v1/messages", ), + case( + AIProvider::AzureFoundry, + "https://example.services.ai.azure.com", + "gpt-6.1-sol", + "responses", + ), case( AIProvider::AzureOpenAI, "https://example.openai.azure.com/openai", diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 28ba52e261..8bbc45721d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -57,7 +57,7 @@ import { } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { workspaceAIClients, getStreamedCompletionText } from '../lib' +import { AI_PROVIDERS, workspaceAIClients, getStreamedCompletionText } from '../lib' import { logFeatureUsage } from '$lib/utils/featureUsage' import { modelSupportsVision } from '../modelConfig' import { getEffectiveModelContextWindow } from '../modelConfig' @@ -251,12 +251,12 @@ const WEB_SEARCH_ERROR_HINT = const REASONING_SUMMARY_WARNED_STORAGE_KEY = 'ai-chat-reasoning-summary-unverified-warned' function providerDisplayName(provider: string): string { - return provider === 'azure_openai' ? 'Azure OpenAI' : 'OpenAI' + return AI_PROVIDERS[provider as keyof typeof AI_PROVIDERS]?.label ?? 'OpenAI' } function reasoningSummaryUnavailableMessage(provider: string): string { const verifyHint = - provider === 'azure_openai' + provider === 'azure_openai' || provider === 'azure_foundry' ? 'To display it, verify your organization with your provider, then reload this page.' : 'To display it, verify your organization in the OpenAI platform settings (Settings > General), then reload this page.' return `This model is reasoning, but your ${providerDisplayName(provider)} organization is not verified to generate reasoning summaries, so its thinking stays hidden. ${verifyHint}` diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 282e7e1aa6..af4b648b61 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -29,7 +29,9 @@ vi.mock('../lib', () => ({ providerSupportsWebSearch: mocks.providerSupportsWebSearch })) -vi.mock('../reasoningRegistry', () => ({ +vi.mock('../reasoningRegistry', async (importOriginal) => ({ + usesOpenAIResponsesApi: (await importOriginal()) + .usesOpenAIResponsesApi, resolveRequestReasoning: mocks.resolveRequestReasoning, resolveEffectiveReasoning: mocks.resolveEffectiveReasoning })) diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index 1e27becba0..343dd268e7 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -10,6 +10,7 @@ import { getCompletion, parseOpenAICompletion, providerSupportsWebSearch } from import { resolveEffectiveReasoning, resolveRequestReasoning, + usesOpenAIResponsesApi, type ReasoningProviderModel } from '../reasoningRegistry' import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' @@ -427,8 +428,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { expect(completionsRejectsToolsWithReasoning('azure_openai', model), model).toBe(false) } }) + it('sends Foundry gpt models that refuse tools on Chat Completions to the Responses API', () => { + expect(usesOpenAIResponsesApi('azure_foundry', 'gpt-6.1-sol')).toBe(true) + for (const model of ['gpt-4o', 'DeepSeek-V4-Pro', 'claude-sonnet-5']) { + expect(usesOpenAIResponsesApi('azure_foundry', model), model).toBe(false) + } + }) it('forwards an explicit off as effort none through OpenRouter', () => { expect( resolveRequestReasoning({ diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 0cbf935832..1cce6e3b0d 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -263,6 +263,19 @@ export function completionsRejectsToolsWithReasoning(provider: AIProvider, model return findReasoningRule(provider, model).rule?.completionsToolsNeedOff ?? false } +/** + * Whether the chat sends this model to the Responses API, falling back to Chat Completions + * where a deployment does not serve it. On Foundry, only the models Chat Completions refuses + * function tools from while they reason: gpt-6.1-sol and gpt-6-astra cannot turn it off. + */ +export function usesOpenAIResponsesApi(provider: AIProvider, model: string): boolean { + return ( + provider === 'openai' || + provider === 'azure_openai' || + (provider === 'azure_foundry' && completionsRejectsToolsWithReasoning(provider, model)) + ) +} + export type ReasoningCapability = { supported: boolean /** Suggested levels for the UI control. Empty when unsupported. */ From 7eee210d761d332f8165b4f8a6f0ee6f1ec8864f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 15 Oct 2026 14:36:39 +0200 Subject: [PATCH 38/49] perf: drive schedule already-exists check from an indexed queue lookup (#11783) * perf: drive schedule already-exists check from v2_job_queue Co-Authored-By: Claude Opus 5.5 * perf: index v2_job_queue by workspace and scheduled_for Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...e21ef62eddd307717b24aa4fc03c0297e8f59.json | 25 +++++++++++++++++++ ...4c9f769ffe5004871a049e4232e0092532062.json | 25 ------------------- ...eue_workspace_scheduled_for_index.down.sql | 1 + ...queue_workspace_scheduled_for_index.up.sql | 10 ++++++++ backend/windmill-api/src/db.rs | 3 +++ backend/windmill-queue/src/schedule.rs | 21 ++++++++++------ 6 files changed, 52 insertions(+), 33 deletions(-) create mode 100644 backend/.sqlx/query-250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59.json delete mode 100644 backend/.sqlx/query-3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062.json create mode 100644 backend/migrations/20261015114712_queue_workspace_scheduled_for_index.down.sql create mode 100644 backend/migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql diff --git a/backend/.sqlx/query-250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59.json b/backend/.sqlx/query-250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59.json new file mode 100644 index 0000000000..52f512d012 --- /dev/null +++ b/backend/.sqlx/query-250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_queue q\n CROSS JOIN LATERAL (\n SELECT trigger_kind, trigger, runnable_path, parent_job\n FROM v2_job WHERE id = q.id OFFSET 0\n ) j\n WHERE q.workspace_id = $1 AND q.scheduled_for = $3\n AND j.trigger_kind = 'schedule' AND j.trigger = $2 AND j.runnable_path = $4\n AND j.parent_job IS NULL\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59" +} diff --git a/backend/.sqlx/query-3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062.json b/backend/.sqlx/query-3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062.json deleted file mode 100644 index a07fcd57db..0000000000 --- a/backend/.sqlx/query-3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS (\n SELECT 1 FROM v2_job j JOIN v2_job_queue USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2 AND runnable_path = $4\n AND parent_job IS NULL\n AND scheduled_for = $3\n )", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Timestamptz", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062" -} diff --git a/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.down.sql b/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.down.sql new file mode 100644 index 0000000000..bebd678a1a --- /dev/null +++ b/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS v2_job_queue_workspace_scheduled_for; diff --git a/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql b/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql new file mode 100644 index 0000000000..63a22e003b --- /dev/null +++ b/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql @@ -0,0 +1,10 @@ +-- Serves push_scheduled_job's already-exists check in windmill-queue/src/schedule.rs, which +-- looks up the queued jobs of one workspace at one occurrence. Without it that check scans the +-- whole queue on every schedule push. +-- Dropped before it is built because the OVERRIDDEN_MIGRATIONS rewrite in +-- windmill-api/src/db.rs runs both CONCURRENTLY: an interrupted concurrent build leaves an +-- invalid index that IF NOT EXISTS would skip rebuilding. +DROP INDEX IF EXISTS v2_job_queue_workspace_scheduled_for; + +CREATE INDEX IF NOT EXISTS v2_job_queue_workspace_scheduled_for + ON v2_job_queue (workspace_id, scheduled_for); diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index f269235460..f0ab31b2c9 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -116,6 +116,9 @@ lazy_static::lazy_static! { (20261009173546, include_str!( "../../migrations/20261009173546_agent_owner_prefix_index.up.sql" ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")), + (20261015114712, include_str!( + "../../migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql" + ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")), // The widening fails every query the previous release's still-running // servers prepared on the column; `variables::widen_variable_value` does it // once they are gone. diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 81be3cb7e8..0041dceea0 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -175,15 +175,20 @@ pub async fn push_scheduled_job<'c>( // Scheduled events must be stored in the database in UTC let next = next.with_timezone(&chrono::Utc); let already_exists: bool = sqlx::query_scalar!( - // Query plan: - // - use of the `ix_v2_job_root_by_path` index; hence the `parent_job IS NULL` clause. - // - select from `v2_job` first, then join with `v2_job_queue` to avoid a full table scan - // on `scheduled_for = $3`. + // Must be driven by `v2_job_queue` (via `v2_job_queue_workspace_scheduled_for`), never + // by a `v2_job` path index, which walks the schedule's whole retained history (stats + // underestimate a single path's rows). The lateral reference forces the join order and + // `OFFSET 0` keeps the filters out of the subquery, so `v2_job` is only ever probed by + // primary key. "SELECT EXISTS ( - SELECT 1 FROM v2_job j JOIN v2_job_queue USING (id) - WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2 AND runnable_path = $4 - AND parent_job IS NULL - AND scheduled_for = $3 + SELECT 1 FROM v2_job_queue q + CROSS JOIN LATERAL ( + SELECT trigger_kind, trigger, runnable_path, parent_job + FROM v2_job WHERE id = q.id OFFSET 0 + ) j + WHERE q.workspace_id = $1 AND q.scheduled_for = $3 + AND j.trigger_kind = 'schedule' AND j.trigger = $2 AND j.runnable_path = $4 + AND j.parent_job IS NULL )", &schedule.workspace_id, &schedule.path, From 718aad7396397fe12295a27a947c76221f37ad10 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 15 Oct 2026 15:49:44 +0200 Subject: [PATCH 39/49] feat(cli): sync an app's custom path as a workspace-specific file (#11786) * fix(cli): keep a raw app's custom_path on sync pull The pull rendered raw_app.yaml without custom_path, so every pull removed the line and every push --dry-run listed the file as modified, while the deployed app still had it and push still sent it. Co-Authored-By: Claude Opus 5.5 * fix(cli): send custom_path on app update only when it changed The backend requires admin for any update carrying custom_path, so the value a pull now writes into raw_app.yaml made every non-admin push of that app fail. Co-Authored-By: Claude Opus 5.5 * feat(cli): sync an app's custom path as a workspace-specific file Unless app routes are workspace-prefixed, a custom path is unique across the instance, so a value carried in the app file every workspace shares can never be promoted. Sync now renders it as ..custom_path.yaml next to the app folder, always workspace-specific. A push applies only the target workspace's file and leaves the deployed custom path alone without one; a checkout that still has custom_path in its app file converges on push, and the next pull moves it out. Co-Authored-By: Claude Opus 5.5 * test(cli): drop the custom_path rationale repeated from changedCustomPath Co-Authored-By: Claude Opus 5.5 * fix(cli): apply a custom path pushed with its raw app, and converge a cleared one The raw-app bundle collapse kept one change per group and dropped the custom-path file grouped with it, so a push editing both deployed only the app. App files and their custom-path file now group under the app path in both folder layouts, and the collapse keeps the custom-path change. A cleared custom path (custom_path: '') has no file in the export, so the push diff listed it as added forever; an empty value with nothing deployed is now in sync. Co-Authored-By: Claude Opus 5.5 * fix(cli): never read a file inside a flow or app folder as workspace-specific The workspace-name segment of the workspace-specific patterns spans '/', so a raw-app asset such as settings.prod.custom_path.json was taken for a workspace's file and dropped from the pull. Like fileset children, files inside a flow or app folder are content of that item, whatever their name. Co-Authored-By: Claude Opus 5.5 * fix(cli): recognize a custom-path file only beside its app isCustomPathFile excluded app and flow folders but still matched a fileset child such as data.fileset/settings.custom_path.json, so a push dropped edits to it and a pull renamed it. A custom-path file is now identified by where it sits: its name is [.].custom_path. and no part of its directory is an item's folder. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- cli/src/commands/app/app.ts | 39 ++++- cli/src/commands/app/raw_apps.ts | 13 +- cli/src/commands/sync/sync.ts | 140 +++++++++++++--- cli/src/core/specific_items.ts | 45 +++++- cli/src/guidance/skills.gen.ts | 2 + cli/src/types.ts | 11 +- cli/src/utils/git.ts | 6 +- cli/test/git_unit.test.ts | 6 +- cli/test/push_diff_convergence_unit.test.ts | 149 ++++++++++++++++++ .../auto-generated/skills/raw-app/SKILL.md | 2 + system_prompts/base/raw-app.md | 4 + 11 files changed, 378 insertions(+), 39 deletions(-) diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 0060c2c422..b4d8230bdb 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -34,6 +34,7 @@ export interface AppFile { public?: boolean; sandbox?: boolean; frontend_api?: string[]; + custom_path?: string; summary: string; policy: Policy; // Mirrors granular ACLs on the app path. Omitted from app.yaml when no @@ -123,6 +124,36 @@ export function stripEditorState(app: any) { delete app.value.parent_version; } } +/** The backend requires admin for any update that carries `custom_path`, even + * an unchanged one, so a value read from a checkout must only be sent when it + * differs from the deployed one or a non-admin can no longer deploy the app. */ +export function changedCustomPath( + local: string | undefined, + deployed: { custom_path?: string } | undefined, +): string | undefined { + return (local ?? "") === (deployed?.custom_path ?? "") ? undefined : local; +} + +/** Applies a workspace's `..custom_path.yaml`. An empty value + * clears the custom path; the app (low-code or raw) must already exist. */ +export async function pushAppCustomPath( + workspace: string, + remotePath: string, + localFile: { custom_path?: string } | undefined, +): Promise { + remotePath = remotePath.replaceAll(SEP, "/"); + const deployed = await wmill.getAppByPath({ workspace, path: remotePath }); + const customPath = changedCustomPath(localFile?.custom_path ?? "", deployed); + if (customPath === undefined) { + return; + } + log.info(colors.bold.yellow(`Setting custom path of app ${remotePath}...`)); + await wmill.updateApp({ + workspace, + path: remotePath, + requestBody: { custom_path: customPath, skip_draft_deletion: true }, + }); +} export function isExecutionModeAnonymous(app: any) { return app?.["policy"]?.["execution_mode"] == "anonymous"; } @@ -269,7 +300,8 @@ export async function pushApp( remotePath: string, localPath: string, message?: string, - permissionedAsContext?: PermissionedAsContext + permissionedAsContext?: PermissionedAsContext, + customPathFromFile = true ): Promise { if (alreadySynced.includes(localPath)) { return; @@ -326,14 +358,17 @@ export async function pushApp( extra_perms: localPerms, sandbox: _sandbox, frontend_api: _frontendApi, + custom_path: localCustomPath, ...localAppBody } = localApp as AppFile & { extra_perms?: Record; }; + const customPath = customPathFromFile ? localCustomPath : undefined; if (app) { if ( isSuperset(localAppBody, app) && + changedCustomPath(customPath, app) === undefined && isolationUpToDate(isolation, deployedPolicy, false) ) { log.info(colors.green(`App ${remotePath} is up to date`)); @@ -345,6 +380,7 @@ export async function pushApp( requestBody: { deployment_message: message, ...localAppBody, + custom_path: changedCustomPath(customPath, app), ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, @@ -360,6 +396,7 @@ export async function pushApp( path: remotePath, deployment_message: message, ...localAppBody, + ...(customPath ? { custom_path: customPath } : {}), ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index e77e1aed45..ec526f2af2 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -22,6 +22,7 @@ import { type AppExecutionMode, type AppIsolation, basePolicy, + changedCustomPath, executionModeForPush, finalizeDerivedPolicy, isolationUpToDate, @@ -376,6 +377,7 @@ export async function pushRawApp( message?: string, defaultTs: "bun" | "deno" = "bun", permissionedAsContext?: PermissionedAsContext, + customPathFromFile = true, ): Promise { if (alreadySynced.includes(localPath)) { return; @@ -496,16 +498,19 @@ export async function pushRawApp( extra_perms: localPerms, sandbox: _sandbox, frontend_api: _frontendApi, + custom_path: localCustomPath, ...localAppNoPerms } = localApp as AppFile & { extra_perms?: Record; }; + const customPath = customPathFromFile ? localCustomPath : undefined; if (app) { // Check both metadata/runnables AND files for changes // Files need separate comparison because isSuperset only checks if local keys exist in remote const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app) && + changedCustomPath(customPath, app) === undefined && isolationUpToDate(isolation, deployedPolicy, true); const filesUpToDate = deepEqual(files, app.value?.files); if (metadataUpToDate && filesUpToDate) { @@ -526,9 +531,7 @@ export async function pushRawApp( deployment_message: message, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, - ...(localApp.custom_path - ? { custom_path: localApp.custom_path } - : {}), + custom_path: changedCustomPath(customPath, app), }, js, css, @@ -548,9 +551,7 @@ export async function pushRawApp( deployment_message: message, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, - ...(localApp.custom_path - ? { custom_path: localApp.custom_path } - : {}), + ...(customPath ? { custom_path: customPath } : {}), }, js, css, diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index cf58347cfc..3563089d17 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -101,6 +101,7 @@ import { isCurrentWorkspaceFile, isItemTypeConfigured, isSpecificItem, + isCustomPathFile, SpecificItemsConfig, } from "../../core/specific_items.ts"; import { @@ -1067,7 +1068,7 @@ async function pushFilesetParentResource( if (cachedWsName && isWorkspaceSpecificFile(resourceFilePath)) { serverPath = fromWorkspaceSpecificPath(resourceFilePath, cachedWsName); wsSpecific = true; - } else if (specificItems && isSpecificItem(childPath, specificItems)) { + } else if (isSpecificItem(childPath, specificItems)) { wsSpecific = true; } @@ -1367,6 +1368,8 @@ export function ZipFSElement( markAccessFromPolicy(app); markIsolationFromPolicy(app, false); app.policy = undefined; + // Rendered as its own workspace-specific file (see below). + delete app.custom_path; yield { isDirectory: false, path: path.join(finalPath, "app.yaml"), @@ -1387,8 +1390,7 @@ export function ZipFSElement( markIsolationFromPolicy(rawApp, true); // console.log("rawApp", rawApp); rawApp.policy = undefined; - // custom_path is derived from the file path, don't store it - delete rawApp?.["custom_path"]; + delete rawApp.custom_path; let inlineScripts; const value = rawApp?.["value"]; const runnables = value?.["runnables"] ?? {}; @@ -1845,6 +1847,24 @@ export function ZipFSElement( } } } + if (kind == "app" || kind == "raw_app") { + // Always workspace-specific: see isCustomPathFile. + const customPath = JSON.parse(await f.async("text"))?.custom_path; + if (customPath) { + const appPath = removeSuffix(p, kind == "app" ? ".app.json" : ".raw_app.json"); + const obj = { custom_path: customPath }; + r.push({ + isDirectory: false, + path: appPath + (useYaml ? ".custom_path.yaml" : ".custom_path.json"), + async *getChildren() {}, + async getContentText() { + return useYaml + ? yamlStringify(obj, yamlOptions) + : JSON.stringify(obj, null, 2); + }, + }); + } + } return r; } function _internal_folder(p: string, zip: JSZip): DynFSElement { @@ -2107,7 +2127,11 @@ export async function elementsToMap( continue; } if (skips.skipFlows && fileType === "flow") continue; - if (skips.skipApps && fileType === "app") continue; + if ( + skips.skipApps && + (fileType === "app" || fileType === "custom_path") + ) + continue; if (skips.skipFolders && fileType === "folder") continue; if ( skips.skipWorkspaceDependencies && @@ -2605,6 +2629,53 @@ function stripUnappliedSettingsFields(local: any, remote: any) { } } +/** + * A checkout made before custom paths had their own file still carries one in + * its app file, and a push deploys it. It compares as the workspace's + * custom-path file it stands for, unless that file exists, so such a checkout + * neither lists its app file as edited nor drops the custom path. + */ +function liftInFileCustomPaths( + localMap: Record, + json: boolean, +): void { + for (const key of Object.keys(localMap)) { + const normalized = key.replaceAll(SEP, "/"); + const type = isRawAppFolderMetadataFile(normalized) + ? "raw_app" + : isAppFolderMetadataFile(normalized) + ? "app" + : undefined; + if (!type || normalized.endsWith(json ? ".yaml" : ".json")) continue; + const appPath = extractResourceName(normalized, type); + let obj: any; + try { + obj = json + ? JSON.parse(localMap[key]) + : yamlParseContent(key, localMap[key]); + } catch { + continue; + } + if (!appPath || !obj || typeof obj != "object" || !("custom_path" in obj)) { + continue; + } + const customPath = obj.custom_path; + delete obj.custom_path; + localMap[key] = json + ? JSON.stringify(obj, null, 2) + : yamlStringify(obj, yamlOptions); + const fileKey = + appPath.replaceAll("/", SEP) + + (json ? ".custom_path.json" : ".custom_path.yaml"); + if (customPath && !(fileKey in localMap)) { + const file = { custom_path: customPath }; + localMap[fileKey] = json + ? JSON.stringify(file, null, 2) + : yamlStringify(file, yamlOptions); + } + } +} + export async function compareDynFSElement( els1: DynFSElement, els2: DynFSElement | undefined, @@ -2732,6 +2803,22 @@ export async function compareDynFSElement( ); } + if (isEls1Remote === false) { + liftInFileCustomPaths(m1, json); + // `custom_path: ''` clears the deployed one, after which the export has + // no file to match it: an empty value with nothing deployed is in sync. + for (const [k, v] of Object.entries(m1)) { + if (!isCustomPathFile(k) || k in m2) continue; + let value: unknown; + try { + value = (json ? JSON.parse(v) : yamlParseContent(k, v))?.custom_path; + } catch { + continue; + } + if (!value) delete m1[k]; + } + } + const changes: Change[] = []; function parseYaml(k: string, v: string) { @@ -2881,6 +2968,11 @@ export async function compareDynFSElement( const remoteCodebase: Record = {}; for (const [k] of Object.entries(m2)) { if (m1[k] === undefined) { + // A workspace without its own custom-path file leaves the deployed one + // alone: a promotion never carries the target's custom path. + if (isEls1Remote === false && isCustomPathFile(k)) { + continue; + } if ( !ignoreMetadataDeletion || (!k?.endsWith(".script.yaml") && !k?.endsWith(".script.json")) @@ -2984,8 +3076,10 @@ function getOrderFromPath(p: string) { return 11; } else if (typ == "app") { return 12; - } else if (typ == "schedule") { + } else if (typ == "custom_path") { return 13; + } else if (typ == "schedule") { + return 14; } else if ( typ == "http_trigger" || typ == "websocket_trigger" || @@ -3000,9 +3094,9 @@ function getOrderFromPath(p: string) { typ == "email_trigger" || typ == "native_trigger" ) { - return 14; - } else { return 15; + } else { + return 16; } } @@ -3923,7 +4017,7 @@ export async function pull( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), - ...(specificItems && isSpecificItem(change.path, specificItems) + ...(isSpecificItem(change.path, specificItems) ? { workspace_specific: true, workspace_specific_path: getWorkspaceSpecificPath( @@ -3972,7 +4066,7 @@ export async function pull( const change: Change = rawChange; // Determine if this file should be written to a workspace-specific path let targetPath = change.path; - if (specificItems && isSpecificItem(change.path, specificItems)) { + if (isSpecificItem(change.path, specificItems)) { const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, @@ -4233,7 +4327,7 @@ export async function pull( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), - ...(specificItems && isSpecificItem(change.path, specificItems) + ...(isSpecificItem(change.path, specificItems) ? { workspace_specific: true, workspace_specific_path: getWorkspaceSpecificPath( @@ -4513,7 +4607,7 @@ function prettyChanges( let wsNote = ""; // Check if this will be written as a workspace-specific file - if (specificItems && isSpecificItem(change.path, specificItems)) { + if (isSpecificItem(change.path, specificItems)) { const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, @@ -5585,7 +5679,7 @@ export async function push( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), - ...(specificItems && isSpecificItem(change.path, specificItems) + ...(isSpecificItem(change.path, specificItems) ? { workspace_specific: true, workspace_specific_path: getWorkspaceSpecificPath( @@ -5755,8 +5849,12 @@ export async function push( // `alreadySynced`, and a push touching several files of one bundle then // deploys the script once per file: several versions in a row, of which // only the last is the one the asset graph ends up describing. + // An app's files and its custom-path file group under the app path in + // both folder layouts, so the custom path is applied after the app. const basePath = getScriptBasePathFromModulePath(change.path) ?? + extractResourceName(change.path, "raw_app")?.replaceAll("/", SEP) ?? + extractResourceName(change.path, "app")?.replaceAll("/", SEP) ?? change.path.split(".")[0]; if (!groupedChanges.has(basePath)) { groupedChanges.set(basePath, []); @@ -5846,10 +5944,14 @@ export async function push( changes = [deleteRawApp]; } else { // The app is one bundle, so a single change re-pushes all of it. - // That leaves the loop exactly one change: any skip it takes for - // a raw-app path drops the whole app from the push, and nothing - // downstream records that as a failure. - changes.splice(1, changes.length - 1); + // That leaves the loop exactly one raw-app change: any skip it + // takes for a raw-app path drops the whole app from the push, and + // nothing downstream records that as a failure. The custom path + // is not part of the bundle and is applied after it. + changes = [ + changes[0], + ...changes.slice(1).filter((c) => isCustomPathFile(c.path)), + ]; } } @@ -6031,7 +6133,7 @@ export async function push( // Check if this is a branch-specific item and get the original workspace-specific path let originalWorkspaceSpecificPath: string | undefined; const isWsSpecific = - specificItems && isSpecificItem(change.path, specificItems); + isSpecificItem(change.path, specificItems); if (isWsSpecific) { originalWorkspaceSpecificPath = getWorkspaceSpecificPath( change.path, @@ -6167,7 +6269,7 @@ export async function push( // For branch-specific items, we read from workspace-specific files but push to base server paths let localFilePath = change.path; const isAddedWsSpecific = - specificItems && isSpecificItem(change.path, specificItems); + isSpecificItem(change.path, specificItems); if (isAddedWsSpecific) { const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, @@ -6697,7 +6799,7 @@ export async function push( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), - ...(specificItems && isSpecificItem(change.path, specificItems) + ...(isSpecificItem(change.path, specificItems) ? { workspace_specific: true, workspace_specific_path: getWorkspaceSpecificPath( diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index cbfd52530e..12cc725b53 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -1,6 +1,10 @@ import { minimatch } from "minimatch"; import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; import { isFileResource, isFilesetResource } from "../utils/utils.ts"; +import { + isFolderResourcePathAnyFormat, + isScriptModulePath, +} from "../utils/resource_folders.ts"; import { SyncOptions, findWorkspaceByGitBranch, @@ -37,10 +41,32 @@ function getWorkspaceSpecificTypeKinds(): string[] { 'variable', 'resource', 'schedule', + 'custom_path', ...TRIGGER_TYPES.map(t => `${t}_trigger`), ]; } +/** + * An app's custom path is always workspace-specific, with or without + * specificItems: unless routes are workspace-prefixed it is unique across the + * instance, so one shared value can never be deployed to two workspaces. + */ +export function isCustomPathFile(path: string): boolean { + const p = path.replaceAll('\\', '/'); + const slash = p.lastIndexOf('/'); + const parts = p.slice(slash + 1).split('.'); + return ( + (parts.length == 3 || parts.length == 4) && + parts[parts.length - 2] == 'custom_path' && + (parts[parts.length - 1] == 'yaml' || parts[parts.length - 1] == 'json') && + // It sits beside its app. A file inside an item's folder (app, flow, + // fileset, script module) is that item's content, whatever its name. + !p.slice(0, slash + 1).includes('.') && + !isFolderResourcePathAnyFormat(p) && + !isScriptModulePath(p) + ); +} + /** * Check if a path ends with any trigger type */ @@ -80,7 +106,7 @@ function getFileTypeSuffix(path: string): string | null { * Build regex pattern for all supported item-kind file types */ function buildItemTypePattern(): string { - const basicTypes = ['variable', 'resource', 'schedule']; + const basicTypes = ['variable', 'resource', 'schedule', 'custom_path']; const triggerTypes = TRIGGER_TYPES.map(t => `${t}_trigger`); return `((${basicTypes.join('|')})|(${triggerTypes.join('|')}))`; } @@ -190,6 +216,9 @@ function normalizeJsonToYaml(path: string): string { * Used to determine if workspace-specific files should be used for this type. */ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsConfig | undefined): boolean { + if (isCustomPathFile(path)) { + return true; + } if (!specificItems) { return false; } @@ -231,6 +260,9 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC * Check if a file path should be treated as workspace-specific */ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig | undefined): boolean { + if (isCustomPathFile(path)) { + return true; + } if (!specificItems) { return false; } @@ -399,7 +431,7 @@ export function getWorkspaceSpecificPath( specificItems: SpecificItemsConfig | undefined, workspaceNameOverride?: string ): string | undefined { - if (!specificItems) { + if (!specificItems && !isCustomPathFile(basePath)) { return undefined; } @@ -434,8 +466,9 @@ export function isCurrentWorkspaceFile(path: string, workspaceNameOverride?: str // Their names are arbitrary, so one can look like typed metadata — and since // the patterns match a workspace-name segment with `[^.]` (which spans `/`), // an unguarded child is dropped from the diff or, worse, remapped onto a - // sibling's key and deployed over it. - if (isFilesetResource(path)) { + // sibling's key and deployed over it. The same holds for the files inside a + // flow or app folder. + if (isFilesetResource(path) || isFolderResourcePathAnyFormat(path)) { return false; } let currentWorkspace: string | null = null; @@ -473,8 +506,8 @@ export function isCurrentWorkspaceFile(path: string, workspaceNameOverride?: str * Used to identify and skip files from other branches during sync operations */ export function isWorkspaceSpecificFile(path: string): boolean { - // Same fileset exemption as isCurrentWorkspaceFile. - if (isFilesetResource(path)) { + // Same exemptions as isCurrentWorkspaceFile. + if (isFilesetResource(path) || isFolderResourcePathAnyFormat(path)) { return false; } const typePattern = buildItemTypePattern(); diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 4a7ef9bbbe..8d33007984 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6922,6 +6922,8 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. \`public: true\` in \`raw_app.yaml\` deploys the app for anonymous users, and \`guests: true\` for guests. Remove the line and the next push closes the app again. +An app's custom URL is not in \`raw_app.yaml\`: each workspace has its own file next to the app folder, \`..custom_path.yaml\` (for example \`f/sales/dashboard.prod.custom_path.yaml\` holding \`custom_path: dashboard\`), where \`\` is the workspace's name in \`wmill.yaml\`, or its id. A push applies only the target workspace's file and leaves the deployed custom URL alone when there is none, so deleting the file does not remove the URL: \`custom_path: ''\` does. Setting or clearing one takes a workspace admin. + ## Sandbox isolation and frontend API access New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. diff --git a/cli/src/types.ts b/cli/src/types.ts index b908b5a7ba..1c97c4d7a4 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -6,7 +6,7 @@ import { sep as SEP } from "node:path"; import { stringify as yamlStringify } from "yaml"; import { yamlParseContent } from "./utils/yaml.ts"; import { isDbtDescriptorPath } from "./utils/resource_folders.ts"; -import { pushApp } from "./commands/app/app.ts"; +import { pushApp, pushAppCustomPath } from "./commands/app/app.ts"; import { pushFolder } from "./commands/folder/folder.ts"; import { pushFlow } from "./commands/flow/flow.ts"; import { pushResource } from "./commands/resource/resource.ts"; @@ -231,13 +231,16 @@ export async function pushObj( if (!appName) { throw new Error(`Could not extract app name from path: ${p}`); } - await pushApp(workspace, appName, buildFolderPath(appName, "app"), message, permissionedAsContext); + // A sync carries the custom path in its own workspace-specific file. + await pushApp(workspace, appName, buildFolderPath(appName, "app"), message, permissionedAsContext, false); } else if (typeEnding === "raw_app") { const rawAppName = extractResourceName(p, "raw_app"); if (!rawAppName) { throw new Error(`Could not extract raw app name from path: ${p}`); } - await pushRawApp(workspace, rawAppName, buildFolderPath(rawAppName, "raw_app"), message, defaultTs, permissionedAsContext); + await pushRawApp(workspace, rawAppName, buildFolderPath(rawAppName, "raw_app"), message, defaultTs, permissionedAsContext, false); + } else if (typeEnding === "custom_path") { + await pushAppCustomPath(workspace, removeType(p, "custom_path"), newObj); } else if (typeEnding === "folder") { await pushFolder(workspace, p, befObj, newObj); } else if (typeEnding === "variable") { @@ -358,6 +361,7 @@ export function getTypeStrFromPath( | "folder" | "app" | "raw_app" + | "custom_path" | "schedule" | "http_trigger" | "websocket_trigger" @@ -452,6 +456,7 @@ export function getTypeStrFromPath( typeEnding === "resource" || typeEnding === "resource-type" || typeEnding === "app" || + typeEnding === "custom_path" || typeEnding === "schedule" || typeEnding === "http_trigger" || typeEnding === "websocket_trigger" || diff --git a/cli/src/utils/git.ts b/cli/src/utils/git.ts index 354e15c36e..29796c2f7c 100644 --- a/cli/src/utils/git.ts +++ b/cli/src/utils/git.ts @@ -388,10 +388,12 @@ export function gitSyncIncludePattern( switch (pathType) { case "flow": return `${path}.flow/*,${path}__flow/*`; + // An app's custom path is a sibling workspace-specific file: the export's + // `.custom_path.*` and the checkout's `..custom_path.*`. case "app": - return `${path}.app/*,${path}__app/*`; + return `${path}.app/*,${path}__app/*,${path}.custom_path.*,${path}.*.custom_path.*`; case "raw_app": - return `${path}.raw_app/**,${path}__raw_app/**`; + return `${path}.raw_app/**,${path}__raw_app/**,${path}.custom_path.*,${path}.*.custom_path.*`; case "folder": return `${path}/folder.meta.*`; case "resourcetype": diff --git a/cli/test/git_unit.test.ts b/cli/test/git_unit.test.ts index 92d44e09d9..2cce1617e2 100644 --- a/cli/test/git_unit.test.ts +++ b/cli/test/git_unit.test.ts @@ -364,9 +364,11 @@ describe("gitSyncIncludePattern", () => { }); test("flow/app expand to dotted + __ folder patterns", () => { expect(gitSyncIncludePattern("flow", "f/x")).toBe("f/x.flow/*,f/x__flow/*"); - expect(gitSyncIncludePattern("app", "f/x")).toBe("f/x.app/*,f/x__app/*"); + expect(gitSyncIncludePattern("app", "f/x")).toBe( + "f/x.app/*,f/x__app/*,f/x.custom_path.*,f/x.*.custom_path.*" + ); expect(gitSyncIncludePattern("raw_app", "f/x")).toBe( - "f/x.raw_app/**,f/x__raw_app/**" + "f/x.raw_app/**,f/x__raw_app/**,f/x.custom_path.*,f/x.*.custom_path.*" ); }); test("folder and triggers", () => { diff --git a/cli/test/push_diff_convergence_unit.test.ts b/cli/test/push_diff_convergence_unit.test.ts index a6f2c132c0..fd20f0d263 100644 --- a/cli/test/push_diff_convergence_unit.test.ts +++ b/cli/test/push_diff_convergence_unit.test.ts @@ -8,6 +8,8 @@ import { compareDynFSElement, ZipFSElement, } from "../src/commands/sync/sync.ts"; +import { changedCustomPath } from "../src/commands/app/app.ts"; +import { isCustomPathFile } from "../src/core/specific_items.ts"; // The differ also reads the working tree (shared lockfiles, dependency files); // an empty one keeps that out of the picture. @@ -346,3 +348,150 @@ test("push: settings.yaml without instance_groups is not a change", async () => }); expect(await diff(otherGroups, remote, skips)).toEqual(["edited settings.yaml"]); }); + +function remoteRawApp(customPath?: string, files: Record = {}) { + const zip = new JSZip(); + zip.file( + "f/test/myapp.raw_app.json", + JSON.stringify({ + summary: "My app", + policy: { execution_mode: "publisher" }, + ...(customPath ? { custom_path: customPath } : {}), + value: { files, runnables: {} }, + }), + { createFolders: false }, + ); + return ZipFSElement(zip, true, "bun", {}, {}, false, true) as any; +} + +async function rendered(el: Mock): Promise> { + const files: Record = {}; + const walk = async (el: Mock) => { + for await (const child of el.getChildren()) { + if (child.isDirectory) await walk(child); + else files[slashPath(child.path)] = await child.getContentText(); + } + }; + await walk(el); + return files; +} + +// Workspace-named as push and pull name it, without any specificItems. +async function diffIn(ws: string, localEl: Mock, remoteEl: Mock, pull: boolean) { + const { changes } = await compareDynFSElement( + (pull ? remoteEl : localEl) as any, + (pull ? localEl : remoteEl) as any, + noIgnore, + false, + {} as any, + !pull, + [], + pull, + undefined, + ws, + pull, + false, + ); + return changes.map((c) => `${c.name} ${slashPath(c.path)}`); +} + +test("a custom path is its own workspace-specific file", async () => { + const files = await rendered(remoteRawApp("my-dashboard")); + expect(files["f/test/myapp.custom_path.yaml"]).toBe( + "custom_path: my-dashboard\n", + ); + const rawAppYaml = files["f/test/myapp.raw_app/raw_app.yaml"]; + expect(rawAppYaml).toBeDefined(); + expect(rawAppYaml).not.toContain("custom_path"); + + const { "f/test/myapp.custom_path.yaml": _, ...appFiles } = files; + const checkout = (extra: Record) => + local({ ...appFiles, ...extra }); + + // The workspace's own file is the one compared; another's is ignored. + const own = checkout({ + "f/test/myapp.prod.custom_path.yaml": "custom_path: my-dashboard\n", + "f/test/myapp.staging.custom_path.yaml": "custom_path: staging-dash\n", + }); + for (const pull of [true, false]) { + expect(await diffIn("prod", own, remoteRawApp("my-dashboard"), pull)).toEqual([]); + } + expect( + await diffIn("staging", own, remoteRawApp("my-dashboard"), false), + ).toEqual(["edited f/test/myapp.custom_path.yaml"]); + + // Promotion: a checkout without the target's file leaves its path alone on + // push, and a pull brings it in as the target's file. + const promoted = checkout({ + "f/test/myapp.staging.custom_path.yaml": "custom_path: staging-dash\n", + }); + expect(await diffIn("prod", promoted, remoteRawApp("my-dashboard"), false)).toEqual([]); + expect(await diffIn("prod", promoted, remoteRawApp("my-dashboard"), true)).toEqual([ + "added f/test/myapp.custom_path.yaml", + ]); + // A pull mirrors a cleared custom path by deleting the workspace's file. + expect(await diffIn("prod", own, remoteRawApp(), true)).toEqual([ + "deleted f/test/myapp.custom_path.yaml", + ]); + // `custom_path: ''` clears it on push, and is in sync once it is cleared. + const cleared = checkout({ + "f/test/myapp.prod.custom_path.yaml": "custom_path: ''\n", + }); + expect(await diffIn("prod", cleared, remoteRawApp("my-dashboard"), false)).toEqual([ + "edited f/test/myapp.custom_path.yaml", + ]); + expect(await diffIn("prod", cleared, remoteRawApp(), false)).toEqual([]); +}); + +test("an app file named like a custom-path file is app content", async () => { + const remote = () => + remoteRawApp(undefined, { "/settings.prod.custom_path.json": "{}" }); + const files = await rendered(remote()); + expect(files["f/test/myapp.raw_app/settings.prod.custom_path.json"]).toBe("{}"); + expect(await diffIn("prod", local({}), remote(), true)).toContain( + "added f/test/myapp.raw_app/settings.prod.custom_path.json", + ); + expect(await diffIn("prod", local(files), remote(), false)).toEqual([]); +}); + +test("a fileset child named like a custom-path file is resource content", async () => { + const child = "f/res/data.fileset/settings.prod.custom_path.json"; + expect(isCustomPathFile(child)).toBe(false); + expect(isCustomPathFile("f/test/myapp.prod.custom_path.yaml")).toBe(true); + expect( + await diffIn("prod", local({ [child]: "{\"v\":2}" }), local({ [child]: "{\"v\":1}" }), false), + ).toEqual([`edited ${child}`]); +}); + +// A checkout from before custom paths had their own file keeps the value in +// raw_app.yaml: a push must converge on it, and a pull moves it out. +test("an in-file custom path converges on push", async () => { + const files = await rendered(remoteRawApp("my-dashboard")); + const { "f/test/myapp.custom_path.yaml": _, ...appFiles } = files; + const inFile = (value: string) => + local({ + ...appFiles, + "f/test/myapp.raw_app/raw_app.yaml": + files["f/test/myapp.raw_app/raw_app.yaml"] + `custom_path: ${value}\n`, + }); + + expect(await diffIn("prod", inFile("my-dashboard"), remoteRawApp("my-dashboard"), false)).toEqual([]); + expect(await diffIn("prod", inFile("other"), remoteRawApp("my-dashboard"), false)).toEqual([ + "edited f/test/myapp.custom_path.yaml", + ]); + expect(await diffIn("prod", inFile("my-dashboard"), remoteRawApp("my-dashboard"), true)).toEqual([ + "edited f/test/myapp.raw_app/raw_app.yaml", + "added f/test/myapp.custom_path.yaml", + ]); +}); + +test("push: an unchanged custom_path is not sent", () => { + expect( + changedCustomPath("my-dashboard", { custom_path: "my-dashboard" }), + ).toBeUndefined(); + expect(changedCustomPath(undefined, { custom_path: "my-dashboard" })).toBeUndefined(); + expect(changedCustomPath("", undefined)).toBeUndefined(); + expect(changedCustomPath("new", { custom_path: "my-dashboard" })).toBe("new"); + expect(changedCustomPath("", { custom_path: "my-dashboard" })).toBe(""); + expect(changedCustomPath("new", undefined)).toBe("new"); +}); diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index a43ea23b38..7a5667c106 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -479,6 +479,8 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. `public: true` in `raw_app.yaml` deploys the app for anonymous users, and `guests: true` for guests. Remove the line and the next push closes the app again. +An app's custom URL is not in `raw_app.yaml`: each workspace has its own file next to the app folder, `..custom_path.yaml` (for example `f/sales/dashboard.prod.custom_path.yaml` holding `custom_path: dashboard`), where `` is the workspace's name in `wmill.yaml`, or its id. A push applies only the target workspace's file and leaves the deployed custom URL alone when there is none, so deleting the file does not remove the URL: `custom_path: ''` does. Setting or clearing one takes a workspace admin. + ## Sandbox isolation and frontend API access New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index fbaca028c0..1323e4228a 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -244,6 +244,10 @@ Either one lets those people run the app's backend runnables, so never open an a When a deploy widens who may open the app, tell the user in plain words. + + +An app's custom URL is not in `raw_app.yaml`: each workspace has its own file next to the app folder, `..custom_path.yaml` (for example `f/sales/dashboard.prod.custom_path.yaml` holding `custom_path: dashboard`), where `` is the workspace's name in `wmill.yaml`, or its id. A push applies only the target workspace's file and leaves the deployed custom URL alone when there is none, so deleting the file does not remove the URL: `custom_path: ''` does. Setting or clearing one takes a workspace admin. + ## Sandbox isolation and frontend API access From f662828a30b0c53df9cb00122d3c3e108e70b68b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 15 Oct 2026 16:23:12 +0200 Subject: [PATCH 40/49] test: fix backend integration tests broken since #11777 (#11791) * test: expect app scoping to refuse a foreign app_script id in app_preview_auth #11777 scopes run-mode app_script ids to their app, so another app's id is now refused with 401 before the content-hash pin check is reached. Co-Authored-By: Claude Opus 5.5 * test: run end_user_email inline scripts through the deployed app_script id #11777 runs inline code only through the app_script rows the deployed lite value references once it exists, so id-less raw_code is refused. Shape the fixtures like real apps (nested inlineScript) and run by the deployed id. Co-Authored-By: Claude Opus 5.5 * test: wait for both dedicated worker jobs on one listener The second completed-job listener was opened only after the first job completed, so a second job that finished first was never seen and the test hung until the 90s worker timeout. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/tests/app_preview_auth.rs | 20 +++-- backend/tests/dedicated_workers.rs | 47 ++++++----- backend/tests/end_user_email.rs | 123 ++++++++++++++++------------- 3 files changed, 99 insertions(+), 91 deletions(-) diff --git a/backend/tests/app_preview_auth.rs b/backend/tests/app_preview_auth.rs index cd84e6f278..f8e90ce144 100644 --- a/backend/tests/app_preview_auth.rs +++ b/backend/tests/app_preview_auth.rs @@ -312,13 +312,11 @@ async fn test_app_preview_authorization(db: Pool) -> anyhow::Result<() "run-mode unpinned raw_code must be rejected for any caller, not just operators (got {status}): {body}" ); - // 11. The pin requirement also covers `raw_code` carrying an `app_script` - // `id`. The id resolves to `rawscript/` of any app_script row - // by number (no app scoping), so without the pin a caller could run a - // script belonging to another app against this Viewer app. Here `999777` - // belongs to `u/test-user/private`, not to the targeted `vapp`, and its - // sha is absent from `vapp`'s empty `triggerables_v2` — it must be - // rejected, not fall back to the Viewer default. + // 11. `raw_code` carrying an `app_script` `id` from another app must not run + // against this Viewer app: the row carries its own modules and lock, which + // would run under `vapp`'s identity. Here `999777` belongs to + // `u/test-user/private`, not to the targeted `vapp`, so the id lookup + // (scoped to the app) refuses it before any Viewer default applies. let resp = authed( client().post(format!("{base}/u/test-user/vapp")), "OPERATOR_TOKEN", @@ -338,12 +336,12 @@ async fn test_app_preview_authorization(db: Pool) -> anyhow::Result<() let status = resp.status(); let body = resp.text().await?; assert_eq!( - status, 400, - "run-mode raw_code with an unpinned app_script id must be rejected against a Viewer app (got {status}): {body}" + status, 401, + "run-mode raw_code with another app's app_script id must be rejected against a Viewer app (got {status}): {body}" ); assert!( - body.contains("forbidden by policy"), - "rejection must be the content-hash pin (unpinned app_script sha), got: {body}" + body.contains("does not belong to app u/test-user/vapp"), + "rejection must be the app scoping of the app_script id, got: {body}" ); // 12. Path confinement: the scope picker mints `apps:run:` / diff --git a/backend/tests/dedicated_workers.rs b/backend/tests/dedicated_workers.rs index 5e9c371f90..d0ba3e5e63 100644 --- a/backend/tests/dedicated_workers.rs +++ b/backend/tests/dedicated_workers.rs @@ -23,6 +23,20 @@ mod dedicated_worker_tests { WorkspacedPath { workspace_id: workspace.to_string(), path: path.to_string() } } + /// Waits on one listener for every job: the jobs may complete in any order, and a listener + /// opened after one completes never sees that notification. + async fn wait_for_all( + mut listener: impl futures::Stream + Unpin, + jobs: &[uuid::Uuid], + ) { + use futures::StreamExt; + let mut pending: std::collections::HashSet<_> = jobs.iter().copied().collect(); + while !pending.is_empty() { + let id = listener.next().await.expect("completed job listener ended"); + pending.remove(&id); + } + } + /// Assert that a completed job (or any descendant job) was executed /// by a dedicated worker process with the expected mode by checking job logs /// for the "dedicated worker {mode}:" prefix. @@ -280,10 +294,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - // Wait for both jobs to complete - listener.find(&uuid_a).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_b).await; + wait_for_all(listener, &[uuid_a, uuid_b]).await; }, port, vec![ @@ -533,9 +544,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_a).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_b).await; + wait_for_all(listener, &[uuid_a, uuid_b]).await; }, port, vec![ @@ -659,9 +668,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_standalone).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_flow).await; + wait_for_all(listener, &[uuid_standalone, uuid_flow]).await; }, port, vec![ @@ -824,9 +831,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_a).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_b).await; + wait_for_all(listener, &[uuid_a, uuid_b]).await; }, port, vec![ @@ -898,9 +903,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_pre).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_other).await; + wait_for_all(listener, &[uuid_pre, uuid_other]).await; }, port, vec![ @@ -971,9 +974,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_branch).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_default).await; + wait_for_all(listener, &[uuid_branch, uuid_default]).await; }, port, vec![wp("test-workspace", "flow/f/system/dedicated_branch_flow")], @@ -1198,9 +1199,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_pre).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_other).await; + wait_for_all(listener, &[uuid_pre, uuid_other]).await; }, port, vec![ @@ -1281,9 +1280,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_ws1).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_ws2).await; + wait_for_all(listener, &[uuid_ws1, uuid_ws2]).await; }, port, vec![ diff --git a/backend/tests/end_user_email.rs b/backend/tests/end_user_email.rs index 15eb9a64f1..7fad5ab5ef 100644 --- a/backend/tests/end_user_email.rs +++ b/backend/tests/end_user_email.rs @@ -17,6 +17,9 @@ const SAME_WS_EMAIL: &str = "test@windmill.dev"; const OTHER_WS_EMAIL: &str = "other-ws@windmill.dev"; const NO_WS_EMAIL: &str = "no-ws@windmill.dev"; +const GET_EMAIL_CONTENT: &str = + "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }"; + fn client() -> reqwest::Client { reqwest::Client::new() } @@ -38,9 +41,11 @@ async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result< "subgrids": {}, "hiddenInlineScripts": [{ "name": "get_email", - "language": "bun", - "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", - "path": "f/test/email_app/get_email" + "inlineScript": { + "language": "bun", + "content": GET_EMAIL_CONTENT, + "path": "f/test/email_app/get_email" + } }] }, "policy": { @@ -82,11 +87,13 @@ async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Res "value": { "type": "rawapp", "css": "", - "inlineScripts": [{ - "name": "get_email", - "language": "bun", - "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }" - }] + "runnables": { + "get_email": { + "name": "get_email", + "type": "inline", + "inlineScript": { "language": "bun", "content": GET_EMAIL_CONTENT } + } + } }, "policy": { "execution_mode": "anonymous", @@ -183,7 +190,39 @@ async fn run_app_flow( wait_for_job_result(port, token, &job_id).await } -async fn run_app_inline_script( +fn find_inline_script_id(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Object(m) => m + .get("inlineScript") + .and_then(|s| s.get("id")) + .and_then(|id| id.as_i64()) + .or_else(|| m.values().find_map(find_inline_script_id)), + serde_json::Value::Array(a) => a.iter().find_map(find_inline_script_id), + _ => None, + } +} + +/// Once the app's dependency job writes the deployed lite value, inline code runs only through +/// the `app_script` rows it references, so the run must carry that row's id. +async fn deployed_inline_script_id(port: u16, app_path: &str) -> anyhow::Result { + let url = format!( + "http://localhost:{}/api/w/test-workspace/apps/get/lite/{}", + port, app_path + ); + for _ in 0..300 { + let resp = authed(client().get(&url), SAME_WS_TOKEN).send().await?; + if resp.status().is_success() { + let app = resp.json::().await?; + if let Some(id) = find_inline_script_id(&app["value"]) { + return Ok(id); + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + anyhow::bail!("timeout waiting for the deployed app_script id of {app_path}") +} + +async fn run_inline_script( port: u16, token: &str, app_path: &str, @@ -196,9 +235,10 @@ async fn run_app_inline_script( let mut payload = json!({ "args": {}, "component": "get_email", + "id": deployed_inline_script_id(port, app_path).await?, "raw_code": { "language": "bun", - "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", + "content": GET_EMAIL_CONTENT, "path": format!("{}/get_email", app_path) } }); @@ -220,42 +260,6 @@ async fn run_app_inline_script( wait_for_job_result(port, token, &job_id).await } -async fn run_raw_app_inline_script( - port: u16, - token: &str, - app_path: &str, - force_viewer: bool, -) -> anyhow::Result { - let url = format!( - "http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}", - port, app_path - ); - let mut payload = json!({ - "args": {}, - "component": "get_email", - "raw_code": { - "language": "bun", - "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }" - } - }); - if force_viewer { - payload["force_viewer_static_fields"] = json!({}); - } - let resp = authed(client().post(&url), token) - .json(&payload) - .send() - .await?; - if !resp.status().is_success() { - anyhow::bail!( - "raw app inline script run failed: {} - {}", - resp.status(), - resp.text().await? - ); - } - let job_id = resp.text().await?; - wait_for_job_result(port, token, &job_id).await -} - async fn wait_for_job_result(port: u16, token: &str, job_id: &str) -> anyhow::Result { let url = format!( "http://localhost:{}/api/w/test-workspace/jobs_u/completed/get_result/{}", @@ -399,21 +403,21 @@ async fn test_app_wm_end_user_email(db: Pool) -> anyhow::Result<()> { create_app_with_inline_script(port, app_path).await?; // Same workspace user (force_viewer mode works for workspace members) - let result = run_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; + let result = run_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; assert_eq!( result, SAME_WS_EMAIL, "same workspace user should get their email" ); // Other workspace user (uses app's anonymous policy + token lookup) - let result = run_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; + let result = run_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; assert_eq!( result, OTHER_WS_EMAIL, "other workspace user should get their email" ); // No workspace user (uses app's anonymous policy + token lookup) - let result = run_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?; + let result = run_inline_script(port, NO_WS_TOKEN, app_path, false).await?; assert_eq!( result, NO_WS_EMAIL, "no workspace user should get their email" @@ -481,21 +485,21 @@ async fn test_raw_app_wm_end_user_email(db: Pool) -> anyhow::Result<() create_raw_app_with_inline_script(port, app_path).await?; // Same workspace user (force_viewer mode works for workspace members) - let result = run_raw_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; + let result = run_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; assert_eq!( result, SAME_WS_EMAIL, "same workspace user should get their email" ); // Other workspace user (uses app's anonymous policy + token lookup) - let result = run_raw_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; + let result = run_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; assert_eq!( result, OTHER_WS_EMAIL, "other workspace user should get their email" ); // No workspace user (uses app's anonymous policy + token lookup) - let result = run_raw_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?; + let result = run_inline_script(port, NO_WS_TOKEN, app_path, false).await?; assert_eq!( result, NO_WS_EMAIL, "no workspace user should get their email" @@ -550,7 +554,11 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only( })) .send() .await?; - anyhow::ensure!(resp.status().is_success(), "create app: {}", resp.text().await?); + anyhow::ensure!( + resp.status().is_success(), + "create app: {}", + resp.text().await? + ); let execute = |payload: serde_json::Value| async move { let resp = authed( @@ -562,7 +570,11 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only( .json(&payload) .send() .await?; - anyhow::ensure!(resp.status().is_success(), "execute: {}", resp.text().await?); + anyhow::ensure!( + resp.status().is_success(), + "execute: {}", + resp.text().await? + ); Ok::<_, anyhow::Error>(resp.text().await?.trim_matches('"').parse::()?) }; let as_viewer = |job_id: uuid::Uuid| { @@ -588,7 +600,8 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only( .await?; assert!(as_viewer(inline).await?); - let path = execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?; + let path = + execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?; assert!( !as_viewer(path).await?, "a path runnable connected as the viewer because its policy entry said so" From aca7fed33ddffa17b0e78d50a39ded032f6d8ba2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 15 Oct 2026 16:27:41 +0200 Subject: [PATCH 41/49] fix: relock apps in place so imported script changes reach them (#11789) * fix: relock apps in place so imported script changes reach them Co-Authored-By: Claude Opus 5.5 * fix: requeue an app relock whose version was rewritten meanwhile Co-Authored-By: Claude Opus 5.5 * fix: relock every component of the version a superseded app relock requeues Also share raw-app runnable dependency extraction between the worker and the dependency-map rebuild, and take no-key-update row locks, which still order against a deploy's UPDATE without blocking app_script inserts. Co-Authored-By: Claude Opus 5.5 * fix: relock every component when an app relock runs on a newer version Co-Authored-By: Claude Opus 5.5 * fix: resolve a requeued app relock's components when it runs Co-Authored-By: Claude Opus 5.5 * fix: regenerate every lock when an app relock relocks every component Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...6a968143d42c3014152608f7d1b252cd8085c.json | 22 - ...23c85a6e6490c2d9e1dc6ac39112d763f0f75.json | 23 - ...14afc75b7794b2a172b6bee519597b0e2cf69.json | 23 + ...6e78437a5e4d3b5e2341ec5f7725a28870270.json | 16 - ...097cb3bd140e3986780d35706584a368ea215.json | 22 + ...e359770b759e8661687b4503c4b692ecd245e.json | 23 + backend/tests/relock_noop.rs | 606 ++++++++++++++++++ backend/windmill-common/src/apps.rs | 28 +- backend/windmill-dep-map/src/lib.rs | 1 + .../windmill-dep-map/src/raw_app_runnables.rs | 160 +++++ .../src/scoped_dependency_map.rs | 16 +- .../src/trigger_dependents.rs | 8 +- backend/windmill-queue/src/jobs.rs | 16 +- .../windmill-worker/src/worker_lockfiles.rs | 390 +++++------ 14 files changed, 1079 insertions(+), 275 deletions(-) delete mode 100644 backend/.sqlx/query-3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c.json delete mode 100644 backend/.sqlx/query-99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75.json create mode 100644 backend/.sqlx/query-a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69.json delete mode 100644 backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json create mode 100644 backend/.sqlx/query-c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215.json create mode 100644 backend/.sqlx/query-ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e.json create mode 100644 backend/windmill-dep-map/src/raw_app_runnables.rs diff --git a/backend/.sqlx/query-3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c.json b/backend/.sqlx/query-3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c.json deleted file mode 100644 index a97d16b89c..0000000000 --- a/backend/.sqlx/query-3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app_version\n (app_id, value, created_by, raw_app)\n SELECT app_id, value, created_by, raw_app\n FROM app_version WHERE id = $1\n RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c" -} diff --git a/backend/.sqlx/query-99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75.json b/backend/.sqlx/query-99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75.json deleted file mode 100644 index 6fb4205e32..0000000000 --- a/backend/.sqlx/query-99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id FROM app_version WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2) ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75" -} diff --git a/backend/.sqlx/query-a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69.json b/backend/.sqlx/query-a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69.json new file mode 100644 index 0000000000..88026d0fed --- /dev/null +++ b/backend/.sqlx/query-a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2 FOR NO KEY UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "versions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69" +} diff --git a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json deleted file mode 100644 index b492b38f49..0000000000 --- a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270" -} diff --git a/backend/.sqlx/query-c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215.json b/backend/.sqlx/query-c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215.json new file mode 100644 index 0000000000..52dffb5a4c --- /dev/null +++ b/backend/.sqlx/query-c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value FROM app_version WHERE id = $1 FOR NO KEY UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215" +} diff --git a/backend/.sqlx/query-ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e.json b/backend/.sqlx/query-ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e.json new file mode 100644 index 0000000000..6812bfdfc7 --- /dev/null +++ b/backend/.sqlx/query-ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "versions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e" +} diff --git a/backend/tests/relock_noop.rs b/backend/tests/relock_noop.rs index a480fc57ae..8760909be9 100644 --- a/backend/tests/relock_noop.rs +++ b/backend/tests/relock_noop.rs @@ -437,3 +437,609 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool) -> anyhow Ok(()) } + +/// Plants raw app `f/rel/app` with python runnables of the given contents, one of which imports +/// `f/rel/a`, as a version deployed before runnables were keyed by name: recorded under the +/// empty node id and each holding a lock already. Returns its published version. +async fn plant_raw_app_with( + db: &Pool, + runnables: &[(&str, &str)], +) -> anyhow::Result { + let runnables: serde_json::Map = runnables + .iter() + .map(|(id, content)| { + let runnable = serde_json::json!({ "type": "inline", "inlineScript": { + "content": content, "language": "python3", "lock": "# py: 3.12\n" + }}); + (id.to_string(), runnable) + }) + .collect(); + let app_id: i64 = sqlx::query_scalar( + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms) + VALUES ($1, 'f/rel/app', '', '{}'::jsonb, '{}', '{}'::jsonb) RETURNING id", + ) + .bind(W) + .fetch_one(db) + .await?; + let published: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, raw_app) + VALUES ($1, $2, 'test-user', true) RETURNING id", + ) + .bind(app_id) + .bind(serde_json::json!({ "runnables": runnables })) + .fetch_one(db) + .await?; + sqlx::query("UPDATE app SET versions = ARRAY[$1::bigint] WHERE id = $2") + .bind(published) + .bind(app_id) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) + VALUES ($1, 'f/rel/app', 'app', 'f/rel/a', '')", + ) + .bind(W) + .execute(db) + .await?; + Ok(published) +} + +/// [`plant_raw_app_with`] a single runnable `a` importing `f/rel/a`. +async fn plant_raw_app(db: &Pool) -> anyhow::Result { + plant_raw_app_with(db, &[("a", B)]).await +} + +async fn app_lock(db: &Pool, version: i64) -> Option { + runnable_lock(db, version, "a").await +} + +async fn runnable_lock(db: &Pool, version: i64, runnable: &str) -> Option { + sqlx::query_scalar( + "SELECT value->'runnables'->$2->'inlineScript'->>'lock' FROM app_version WHERE id = $1", + ) + .bind(version) + .bind(runnable) + .fetch_one(db) + .await + .unwrap() +} + +const WRITTEN_MEANWHILE: &str = "# py: 3.12\n# written meanwhile\n"; + +/// Once an app dependency job waits on the version row `writer` holds, rewrites that row's +/// lock of runnable `a` to [`WRITTEN_MEANWHILE`] and commits. +async fn write_once_waited_on( + db: &Pool, + mut writer: sqlx::Transaction<'_, Postgres>, + version: i64, +) { + wait_until_blocked(db, "SELECT value FROM app_version%").await; + sqlx::query( + "UPDATE app_version SET value = jsonb_set(value::jsonb, + '{runnables,a,inlineScript,lock}', to_jsonb($2::text))::json WHERE id = $1", + ) + .bind(version) + .bind(WRITTEN_MEANWHILE) + .execute(&mut *writer) + .await + .unwrap(); + writer.commit().await.unwrap(); +} + +/// Waits until a statement of this test's database matching `query` waits on a row lock. +async fn wait_until_blocked(db: &Pool, query: &str) { + let deadline = tokio::time::Instant::now() + WAIT_BUDGET; + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE $1)", + ) + .bind(query) + .fetch_one(db) + .await + .unwrap(); + if waiting { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "nothing ever waited on a row lock for {query}" + ); + tokio::time::sleep(POLL).await; + } +} + +/// Queues a dependency job of `f/rel/app` at `version`: the deploy's own when `components` is +/// `None`, else a relative-import relock of those components. +async fn push_app_dependency_job(db: &Pool, version: i64, components: Option<&[&str]>) { + let payload = windmill_common::jobs::JobPayload::AppDependencies { + path: "f/rel/app".to_string(), + version, + debouncing_settings: Default::default(), + }; + let mut args = std::collections::HashMap::new(); + if let Some(components) = components { + args.insert( + "triggered_by_relative_import".to_string(), + serde_json::value::to_raw_value(&true).unwrap(), + ); + args.insert( + "components_to_relock".to_string(), + serde_json::value::to_raw_value(components).unwrap(), + ); + } + let (_, tx) = windmill_queue::push( + db, + windmill_queue::PushIsolationLevel::IsolatedRoot(db.clone()), + W, + payload, + windmill_queue::PushArgs { args: &args, extra: None }, + "test-user", + "test@windmill.dev", + "u/test-user".to_string(), + None, + None, + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + None, + false, + None, + None, + None, + None, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +/// Logs of every dependency job of `f/rel/app` created after `since`, oldest first. +async fn app_dependency_logs( + db: &Pool, + since: chrono::DateTime, +) -> Vec { + sqlx::query_scalar( + "SELECT COALESCE(l.logs, '') FROM v2_job j + LEFT JOIN job_logs l ON l.job_id = j.id + WHERE j.kind = 'appdependencies' AND j.runnable_path = 'f/rel/app' AND j.created_at > $1 + ORDER BY j.created_at", + ) + .bind(since) + .fetch_all(db) + .await + .unwrap() +} + +/// A raw app runnable importing a script is relocked when that script's dependencies change, +/// and the relock lands in the version the app is served from rather than in a new one: a raw +/// app's bundle is stored under its version id. +#[sqlx::test(fixtures("base"))] +async fn raw_app_relock_rewrites_the_published_version(db: Pool) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let a_hash = live(&versions(&db, "f/rel/a").await).hash; + client + .create_script( + W, + &py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))), + ) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 2).await; + + let (versions, version_rows): (Vec, i64) = sqlx::query_as( + "SELECT versions, (SELECT count(*) FROM app_version WHERE app_id = app.id) + FROM app WHERE workspace_id = $1 AND path = 'f/rel/app'", + ) + .bind(W) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(versions, [published], "the relock publishes no other version"); + assert_eq!(version_rows, 1, "the relock mints no version"); + + let lock = app_lock(&db, published).await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the published version carries the new lock: {lock:?}" + ); + + let node_ids: Vec = sqlx::query_scalar( + "SELECT importer_node_id FROM dependency_map + WHERE workspace_id = $1 AND importer_path = 'f/rel/app' AND imported_path = 'f/rel/a'", + ) + .bind(W) + .fetch_all(&db) + .await + .unwrap(); + assert_eq!(node_ids, ["a"], "the runnable is recorded under its name"); + }, + port, + ) + .await; + + Ok(()) +} + +/// A relock rewrites the one row every other relock of the app, and the dependency job of the +/// deploy that published it, write too. When that row changed after the relock read it, the +/// relock is discarded and queued again on what is there now, rather than overwriting it. +#[sqlx::test(fixtures("base"))] +async fn raw_app_relock_racing_a_write_requeues(db: Pool) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let mut writer = db.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM app_version WHERE id = $1 FOR NO KEY UPDATE") + .bind(published) + .execute(&mut *writer) + .await + .unwrap(); + + let since = chrono::Utc::now(); + let a_hash = live(&versions(&db, "f/rel/a").await).hash; + client + .create_script( + W, + &py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))), + ) + .await + .unwrap(); + + write_once_waited_on(&db, writer, published).await; + + // The leaf's own job, the discarded relock and the one it queued. + wait_for_jobs(&db, &mut completed, 3).await; + + let logs = app_dependency_logs(&db, since).await; + assert_eq!(logs.len(), 2, "one relock and its requeue: {logs:?}"); + assert!( + logs[0].contains("discarding them"), + "the first relock was discarded: {}", + logs[0] + ); + let lock = app_lock(&db, published).await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the queued relock regenerated the lock over the concurrent write: {lock:?}" + ); + }, + port, + ) + .await; + + Ok(()) +} + +/// The dependency job of the deploy that published a version writes the row relocks write +/// too, so it backs off the same way when that row changed after it read it. +#[sqlx::test(fixtures("base"))] +async fn app_deploy_dependency_job_racing_a_write_requeues( + db: Pool, +) -> anyhow::Result<()> { + let (_client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + let mut writer = db.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM app_version WHERE id = $1 FOR NO KEY UPDATE") + .bind(published) + .execute(&mut *writer) + .await + .unwrap(); + + let since = chrono::Utc::now(); + push_app_dependency_job(&db, published, None).await; + + write_once_waited_on(&db, writer, published).await; + + // The deploy's job, and the relock it queued on backing off. + wait_for_jobs(&db, &mut completed, 2).await; + + let logs = app_dependency_logs(&db, since).await; + assert_eq!(logs.len(), 2, "the deploy's job and its requeue: {logs:?}"); + assert!( + logs[0].contains("discarding them"), + "the deploy's job backed off: {}", + logs[0] + ); + }, + port, + ) + .await; + + Ok(()) +} + +/// A raw app runnable ships the runnables it imports as modules, and its lock covers what they +/// import, so a change to a script one of them imports relocks the importer too. +#[sqlx::test(fixtures("base"))] +async fn raw_app_relock_follows_imports_of_imported_runnables( + db: Pool, +) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app_with( + &db, + &[ + ("a", "from .b import g\n\ndef main():\n return g()\n"), + ("b", "from f.rel.a import main as a\n\ndef g():\n return a()\n\ndef main():\n return g()\n"), + ], + ) + .await?; + + in_test_worker( + &db, + async { + // Its relock of the app records what each runnable depends on. + client + .create_script(W, &py_script("f/rel/a", A, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let a_hash = live(&versions(&db, "f/rel/a").await).hash; + client + .create_script( + W, + &py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))), + ) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 2).await; + + for runnable in ["a", "b"] { + let lock = runnable_lock(&db, published, runnable).await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "{runnable} carries the new lock: {lock:?}" + ); + } + + // A rebuilt dependency map records the same dependencies. + async fn app_edges(db: &Pool) -> Vec<(String, String)> { + sqlx::query_as( + "SELECT importer_node_id, imported_path FROM dependency_map + WHERE workspace_id = $1 AND importer_path = 'f/rel/app' ORDER BY 1, 2", + ) + .bind(W) + .fetch_all(db) + .await + .unwrap() + } + let recorded = app_edges(&db).await; + assert!(rebuild_dmap(&client).await); + assert_eq!(app_edges(&db).await, recorded); + }, + port, + ) + .await; + + Ok(()) +} + +/// A relock superseded by a deploy relocks the version that deploy published, every component +/// of it: the deploy may rename the component the relock was for, keeping its stale lock. +#[sqlx::test(fixtures("base"))] +async fn superseded_app_relock_relocks_the_renamed_component( + db: Pool, +) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let mut deploy = db.begin().await.unwrap(); + sqlx::query( + "SELECT 1 FROM app WHERE workspace_id = $1 AND path = 'f/rel/app' FOR NO KEY UPDATE", + ) + .bind(W) + .execute(&mut *deploy) + .await + .unwrap(); + + let a_hash = live(&versions(&db, "f/rel/a").await).hash; + client + .create_script( + W, + &py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))), + ) + .await + .unwrap(); + + // The relock has generated its lock for `a` and waits on the app this deploy holds, + // which publishes `a` renamed to `b` with its old lock. + wait_until_blocked(&db, "SELECT versions[array_upper(versions, 1)] FROM app%").await; + let renamed: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, raw_app) + SELECT app_id, json_build_object('runnables', + json_build_object('b', value->'runnables'->'a')), created_by, true + FROM app_version WHERE id = (SELECT versions[array_upper(versions, 1)] FROM app + WHERE workspace_id = $1 AND path = 'f/rel/app') + RETURNING id", + ) + .bind(W) + .fetch_one(&mut *deploy) + .await + .unwrap(); + sqlx::query( + "UPDATE app SET versions = array_append(versions, $2) + WHERE workspace_id = $1 AND path = 'f/rel/app'", + ) + .bind(W) + .bind(renamed) + .execute(&mut *deploy) + .await + .unwrap(); + deploy.commit().await.unwrap(); + + // The leaf's own job, the superseded relock and the one it queued. + wait_for_jobs(&db, &mut completed, 3).await; + + let lock = runnable_lock(&db, renamed, "b").await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the renamed component carries the new lock: {lock:?}" + ); + }, + port, + ) + .await; + + Ok(()) +} + +/// A relock queued before a deploy runs on the version that deploy published, every component +/// of it: the deploy may rename the component the relock was queued for, keeping its stale lock. +#[sqlx::test(fixtures("base"))] +async fn app_relock_queued_before_a_deploy_relocks_the_renamed_component( + db: Pool, +) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let queued_for = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A_WITH_TINY, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let renamed: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, raw_app) + SELECT app_id, $2, created_by, true FROM app_version WHERE id = $1 + RETURNING id", + ) + .bind(queued_for) + .bind( + serde_json::json!({ "runnables": { "c": { "type": "inline", "inlineScript": { + "content": B, "language": "python3", "lock": "# py: 3.12\n" + }}}}), + ) + .fetch_one(&db) + .await + .unwrap(); + sqlx::query( + "UPDATE app SET versions = array_append(versions, $2) + WHERE workspace_id = $1 AND path = 'f/rel/app'", + ) + .bind(W) + .bind(renamed) + .execute(&db) + .await + .unwrap(); + + push_app_dependency_job(&db, queued_for, Some(&["a"])).await; + wait_for_jobs(&db, &mut completed, 1).await; + + let lock = runnable_lock(&db, renamed, "c").await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the renamed component carries the new lock: {lock:?}" + ); + }, + port, + ) + .await; + + Ok(()) +} + +/// A relock of every component regenerates each lock. A deploy that kept its editor's stale +/// lock still records the imports' current lock hashes, so the skip check would keep it. +#[sqlx::test(fixtures("base"))] +async fn app_relock_of_every_component_regenerates_a_kept_stale_lock( + db: Pool, +) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A_WITH_TINY, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + // The stale lock a deploy kept, under the hashes it recorded. + sqlx::query( + "UPDATE app_version SET value = jsonb_set(value::jsonb, + '{runnables,a,inlineScript,lock}', '\"# py: 3.12\\n\"')::json WHERE id = $1", + ) + .bind(published) + .execute(&db) + .await + .unwrap(); + + let every = windmill_dep_map::trigger_dependents::RELOCK_ALL_APP_COMPONENTS; + push_app_dependency_job(&db, published, Some(&[every])).await; + wait_for_jobs(&db, &mut completed, 1).await; + + let lock = app_lock(&db, published).await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the kept stale lock is regenerated: {lock:?}" + ); + }, + port, + ) + .await; + + Ok(()) +} diff --git a/backend/windmill-common/src/apps.rs b/backend/windmill-common/src/apps.rs index 1686c20d2b..4e25cec17d 100644 --- a/backend/windmill-common/src/apps.rs +++ b/backend/windmill-common/src/apps.rs @@ -71,6 +71,24 @@ pub fn app_value_runnable_paths(value: &Value) -> error::Result( + key: &str, + value: &'a Value, + container_id: Option<&String>, +) -> Option<&'a serde_json::Map> { + match value { + Value::Object(runnables) if container_id.is_none() && key == RAW_APP_RUNNABLES => { + Some(runnables) + } + _ => None, + } +} + /// Traverse FlowValue while invoking provided by caller callback on leafs // #[async_recursion::async_recursion(?Send)] pub fn traverse_app_inline_scripts< @@ -109,7 +127,15 @@ pub fn traverse_app_inline_scripts< )?; } } else { - for (_, value) in object { + for (key, value) in object { + if let Some(runnables) = + raw_app_runnables_map(key, value, container_id.as_ref()) + { + for (id, runnable) in runnables { + traverse_app_inline_scripts(runnable, Some(id.clone()), cb)?; + } + continue; + } traverse_app_inline_scripts( value, object diff --git a/backend/windmill-dep-map/src/lib.rs b/backend/windmill-dep-map/src/lib.rs index aab50795a9..1d47056cf9 100644 --- a/backend/windmill-dep-map/src/lib.rs +++ b/backend/windmill-dep-map/src/lib.rs @@ -2,6 +2,7 @@ pub mod ci_tests; #[cfg(feature = "private")] pub mod ci_tests_ee; pub mod lock_hash; +pub mod raw_app_runnables; pub mod scoped_dependency_map; pub mod trigger_dependents; pub mod workspace_dependencies; diff --git a/backend/windmill-dep-map/src/raw_app_runnables.rs b/backend/windmill-dep-map/src/raw_app_runnables.rs new file mode 100644 index 0000000000..87b148d53c --- /dev/null +++ b/backend/windmill-dep-map/src/raw_app_runnables.rs @@ -0,0 +1,160 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde_json::{from_value, Value}; +use windmill_common::scripts::{ScriptLang, ScriptModule}; + +use crate::{extract_referenced_paths, extract_relative_imports}; + +/// A raw app's inline runnables, keyed by the path a relative import of one resolves to: +/// `/`, the path the runnable itself runs under. A runnable ships +/// the ones it imports as its modules, so they resolve from the job dir at run time. +#[derive(Default)] +pub struct RawAppRunnables(HashMap); + +impl RawAppRunnables { + pub fn from_value(app_path: &str, value: &Value) -> Self { + let mut runnables = HashMap::new(); + if let Some(Value::Object(by_id)) = value.get("runnables") { + for (id, runnable) in by_id { + let Some(script) = runnable.get("inlineScript") else { + continue; + }; + let content = script.get("content").and_then(Value::as_str); + let language = script + .get("language") + .and_then(|l| from_value::(l.clone()).ok()); + if let (Some(content), Some(language)) = (content, language) { + // `main.ts` is the Bun entrypoint's own file in the job dir, so a + // runnable with that id cannot ship as a module next to it. + let ext = module_extension(language); + if ext.is_some() && !(ext == Some("ts") && id == "main") { + runnables.insert( + format!("{app_path}/{id}"), + (id.clone(), language, content.to_string()), + ); + } + } + } + } + Self(runnables) + } + + /// Whether an import of `path` by `language` code resolves to a runnable shipped as a + /// module, rather than to the workspace script at that path. + pub fn resolves(&self, path: &str, language: ScriptLang) -> bool { + self.0.get(path).is_some_and(|(_, sibling_language, _)| { + module_extension(*sibling_language) == module_extension(language) + }) + } + + /// The runnables `content` imports, directly or through one another. + pub fn modules_of( + &self, + app_path: &str, + content: &str, + language: ScriptLang, + ) -> Option> { + let ext = module_extension(language)?; + let mut queue = + extract_relative_imports(content, &format!("{app_path}/app"), &Some(language)) + .unwrap_or_default(); + let mut visited = HashSet::new(); + let mut modules = BTreeMap::new(); + while let Some(path) = queue.pop() { + if !visited.insert(path.clone()) { + continue; + } + let Some((id, sibling_language, sibling_content)) = self.0.get(&path) else { + continue; + }; + if module_extension(*sibling_language) != Some(ext) { + continue; + } + queue.extend( + extract_relative_imports(sibling_content, &path, &Some(*sibling_language)) + .unwrap_or_default(), + ); + modules.insert( + format!("{id}.{ext}"), + ScriptModule { + content: sibling_content.clone(), + language: *sibling_language, + lock: None, + }, + ); + } + (!modules.is_empty()).then_some(modules) + } + + /// What an app inline script depends on, as its dependency-map node records it. An import + /// satisfied by another runnable is not a dependency on the workspace script at that path, + /// but what that runnable imports is: the script's lock covers its modules, so a change + /// there must relock it and fail its skip check. + pub fn referenced_paths( + &self, + app_path: &str, + content: &str, + language: ScriptLang, + ) -> Option> { + let base = format!("{app_path}/app"); + let own = extract_referenced_paths(content, &base, Some(language)); + let Some(modules) = self.modules_of(app_path, content, language) else { + return own.map(|paths| { + paths + .into_iter() + .filter(|p| !self.resolves(p, language)) + .collect() + }); + }; + let mut paths = own.unwrap_or_default(); + for module in modules.values() { + paths.extend( + extract_referenced_paths(&module.content, &base, Some(module.language)) + .unwrap_or_default(), + ); + } + paths.retain(|p| !self.resolves(p, language)); + paths.sort(); + paths.dedup(); + Some(paths) + } +} + +/// The languages whose executors resolve a relative import against a module file. +pub fn module_extension(language: ScriptLang) -> Option<&'static str> { + match language { + ScriptLang::Bun | ScriptLang::Bunnative => Some("ts"), + ScriptLang::Python3 => Some("py"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn modules_follow_imports_between_runnables_of_one_language() { + let value = json!({ "runnables": { + "main": { "inlineScript": { "language": "bun", "content": "import { a } from './a'" } }, + "a": { "inlineScript": { "language": "bun", "content": "import { b } from './b.ts'" } }, + "b": { "inlineScript": { "language": "bun", "content": "export const b = 1" } }, + "unused": { "inlineScript": { "language": "bun", "content": "export const u = 1" } }, + "py": { "inlineScript": { "language": "python3", "content": "x = 1" } }, + }}); + let runnables = RawAppRunnables::from_value("u/admin/app", &value); + + let modules = runnables + .modules_of( + "u/admin/app", + "import { a } from './a'\nimport { p } from './py'", + ScriptLang::Bun, + ) + .unwrap(); + assert_eq!(modules.keys().collect::>(), vec!["a.ts", "b.ts"]); + assert!(runnables + .modules_of("u/admin/app", "export const x = 1", ScriptLang::Bun) + .is_none()); + } +} diff --git a/backend/windmill-dep-map/src/scoped_dependency_map.rs b/backend/windmill-dep-map/src/scoped_dependency_map.rs index ad1093bb76..e57273e43f 100644 --- a/backend/windmill-dep-map/src/scoped_dependency_map.rs +++ b/backend/windmill-dep-map/src/scoped_dependency_map.rs @@ -11,6 +11,8 @@ use windmill_common::{ use std::collections::HashSet; +use crate::raw_app_runnables::RawAppRunnables; + lazy_static::lazy_static! { pub static ref WMDEBUG_NO_DMAP_DISSOLVE: bool = std::env::var("WMDEBUG_NO_DMAP_DISSOLVE").is_ok(); } @@ -506,15 +508,19 @@ SELECT importer_node_id, imported_path, imported_lockfile_hash let mut dmap = ScopedDependencyMap::fetch(w_id, &r.path, "app", db).await?; let mut tx = db.begin().await?; let mut to_process = vec![]; + let runnables = RawAppRunnables::from_value(&r.path, &value); traverse_app_inline_scripts(&value, None, &mut |ais, id| { - to_process.push(( - crate::extract_referenced_paths( + let referenced_paths = match ais.language { + Some(language) => { + runnables.referenced_paths(&r.path, &ais.content, language) + } + None => crate::extract_referenced_paths( &ais.content, &(r.path.clone() + "/app"), - ais.language, + None, ), - id, - )); + }; + to_process.push((referenced_paths, id)); Ok(()) })?; diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index 48e151736a..45671bb132 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -12,6 +12,11 @@ use windmill_queue::PushIsolationLevel; use crate::scoped_dependency_map::{DependencyDependent, ScopedDependencyMap}; +/// A `components_to_relock` entry asking an app relock to relock every component of the version +/// it runs on. It travels in the list itself so that debouncing, which concatenates the lists of +/// the relocks it merges, cannot drop it. +pub const RELOCK_ALL_APP_COMPONENTS: &str = "*"; + lazy_static::lazy_static! { static ref DEPENDENCY_JOB_DEBOUNCE_DELAY: usize = std::env::var("DEPENDENCY_JOB_DEBOUNCE_DELAY").ok().and_then(|flag| flag.parse().ok()).unwrap_or( if cfg!(test) { 15 } else { 5 } @@ -141,12 +146,13 @@ pub async fn trigger_dependents_to_recompute_dependencies( }, "app" => match sqlx::query_scalar!( - "SELECT id FROM app_version WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2) ORDER BY created_at DESC LIMIT 1", + "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2", importer_path, w_id ) .fetch_optional(&mut *tx) .await? + .flatten() { Some(version) => { tracing::debug!("Handling app dependency update for: {}", importer_path); diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index a28b09e0bb..2ed6d034b2 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -4661,7 +4661,9 @@ async fn clone_runnable(j: &mut PulledJob, db: &DB) -> error::Result<()> { // A script gets its new version from the worker, once the generated lock is known // to differ from the live version's: minting one here would deploy, and walk the // importers of, a version whose lock turns out byte-identical to its parent's. - JobKind::Dependencies => *base_hash, + // An app is relocked in place: a raw app's bundle is stored under its version id, + // so a copy minted here could never be published. + JobKind::Dependencies | JobKind::AppDependencies => *base_hash, JobKind::FlowDependencies => { sqlx::query_scalar!( "INSERT INTO flow_version @@ -4679,18 +4681,6 @@ async fn clone_runnable(j: &mut PulledJob, db: &DB) -> error::Result<()> { .fetch_one(db) .await? } - JobKind::AppDependencies => { - sqlx::query_scalar!( - "INSERT INTO app_version - (app_id, value, created_by, raw_app) - SELECT app_id, value, created_by, raw_app - FROM app_version WHERE id = $1 - RETURNING id", - *base_hash - ) - .fetch_one(db) - .await? - } _ => *base_hash, }; diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 43bb32f7eb..4c50e63a8d 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap}; use std::fs::{create_dir_all, remove_dir_all}; #[cfg(feature = "python")] @@ -29,12 +29,14 @@ use windmill_common::workspace_dependencies::{ RawWorkspaceDependencies, WorkspaceDependenciesPrefetched, }; use windmill_dep_map::scoped_dependency_map::{DependencyDependent, ScopedDependencyMap}; -use windmill_dep_map::trigger_dependents::trigger_dependents_to_recompute_dependencies; +use windmill_dep_map::trigger_dependents::{ + trigger_dependents_to_recompute_dependencies, RELOCK_ALL_APP_COMPONENTS, +}; #[cfg(feature = "python")] use windmill_parser_yaml::AnsibleRequirements; use windmill_common::{ - apps::AppScriptId, + apps::{raw_app_runnables_map, AppScriptId, RAW_APP_RUNNABLES}, cache::{self, RawData}, error, flows::{add_virtual_items_if_necessary, FlowValue}, @@ -42,6 +44,7 @@ use windmill_common::{ DB, }; use windmill_dep_map::lock_hash::record_lock_hashes; +use windmill_dep_map::raw_app_runnables::RawAppRunnables; pub use windmill_dep_map::{ extract_referenced_paths, extract_relative_imports, process_relative_imports, refresh_dependency_map, @@ -546,8 +549,14 @@ pub async fn handle_dependency_job( ); tracing::info!(workspace_id = %w_id, job_id = %job.id, "{log_msg}"); append_logs(&job.id, w_id, log_msg, &db.into()).await; - requeue_relock(db, job, script_path, deployment_message, parent_path) - .await?; + requeue_relock( + db, + job, + script_dependent(script_path), + deployment_message, + parent_path, + ) + .await?; return Ok(to_raw_value_owned( json!({ "status": "Lock generation superseded by a newer version", "lock": content }), )); @@ -706,9 +715,14 @@ pub async fn handle_dependency_job( { Ok(RelockOutcome::Deployed(_)) => *deployment_tallied = false, Ok(RelockOutcome::Superseded(_)) => { - if let Err(e) = - requeue_relock(db, job, script_path, deployment_message, parent_path) - .await + if let Err(e) = requeue_relock( + db, + job, + script_dependent(script_path), + deployment_message, + parent_path, + ) + .await { tracing::error!(%e, "error queueing a relock of {script_path}") } @@ -830,14 +844,15 @@ async fn commit_relock( Ok(RelockOutcome::Deployed(ScriptHash(new_hash))) } -/// Queues another relative-import relock of `script_path`, through the same push the fan-out +/// Queues another relative-import relock of `dependent`, through the same push the fan-out /// uses. The version live now was deployed while a lock was generated for its predecessor; -/// when that deploy was a sibling relock it queued nothing for this path, and the result just -/// discarded may have been the one generated against the current imports. +/// when that deploy was a sibling relock it queued nothing for this path, and a deploy of an +/// edited app carries the locks of the version it was edited from, so the result just +/// discarded may have been the only one generated against the current imports. async fn requeue_relock( db: &DB, job: &MiniPulledJob, - script_path: &str, + dependent: DependencyDependent, deployment_message: Option, parent_path: Option, ) -> error::Result<()> { @@ -849,11 +864,7 @@ async fn requeue_relock( .unwrap_or_default(); trigger_dependents_to_recompute_dependencies( &job.workspace_id, - vec![DependencyDependent { - importer_path: script_path.to_string(), - importer_kind: "script".to_string(), - importer_node_ids: None, - }], + vec![dependent], deployment_message, parent_path, &job.permissioned_as_email, @@ -865,6 +876,55 @@ async fn requeue_relock( .await } +fn script_dependent(script_path: &str) -> DependencyDependent { + DependencyDependent { + importer_path: script_path.to_string(), + importer_kind: "script".to_string(), + importer_node_ids: None, + } +} + +/// The version an app is served from, which is the one a relative-import relock rewrites. +async fn published_app_version(db: &DB, w_id: &str, app_path: &str) -> error::Result> { + Ok(sqlx::query_scalar!( + "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2", + app_path, + w_id + ) + .fetch_optional(db) + .await? + .flatten()) +} + +/// The dependency-map node id of every inline script of an app version's `value`. +fn app_component_ids(value: &Value) -> error::Result> { + let mut ids = vec![]; + windmill_common::apps::traverse_app_inline_scripts(value, None, &mut |_, id| { + ids.push(id.unwrap_or_default()); + Ok(()) + })?; + ids.sort(); + ids.dedup(); + Ok(ids) +} + +/// A raw app's runnables are recorded in the dependency map under their name in `runnables`. +/// Versions deployed before that recorded every runnable under the empty node id, so a relock +/// asking for that id relocks each of them. +fn expand_legacy_raw_app_components( + components_to_relock: Option>, + value: &Value, +) -> Option> { + let mut components = components_to_relock?; + if let Some(i) = components.iter().position(String::is_empty) { + components.swap_remove(i); + if let Some(Value::Object(runnables)) = value.get(RAW_APP_RUNNABLES) { + components.extend(runnables.keys().cloned()); + } + } + Some(components) +} + fn remove_ansi_codes(s: &str) -> String { lazy_static::lazy_static! { static ref ANSI_REGEX: regex::Regex = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap(); @@ -2301,97 +2361,6 @@ async fn reduce_flow<'c>( Ok(tx) } -/// A raw app's inline runnables, keyed by the path a relative import of one resolves to: -/// `/`, the path the runnable itself runs under. A runnable ships -/// the ones it imports as its modules, so they resolve from the job dir at run time. -#[derive(Default)] -struct RawAppRunnables(HashMap); - -impl RawAppRunnables { - fn from_value(app_path: &str, value: &Value) -> Self { - let mut runnables = HashMap::new(); - if let Some(Value::Object(by_id)) = value.get("runnables") { - for (id, runnable) in by_id { - let Some(script) = runnable.get("inlineScript") else { - continue; - }; - let content = script.get("content").and_then(Value::as_str); - let language = script - .get("language") - .and_then(|l| from_value::(l.clone()).ok()); - if let (Some(content), Some(language)) = (content, language) { - // `main.ts` is the Bun entrypoint's own file in the job dir, so a - // runnable with that id cannot ship as a module next to it. - let ext = module_extension(language); - if ext.is_some() && !(ext == Some("ts") && id == "main") { - runnables.insert( - format!("{app_path}/{id}"), - (id.clone(), language, content.to_string()), - ); - } - } - } - } - Self(runnables) - } - - /// Whether an import of `path` by `language` code resolves to a runnable shipped as a - /// module, rather than to the workspace script at that path. - fn resolves(&self, path: &str, language: ScriptLang) -> bool { - self.0.get(path).is_some_and(|(_, sibling_language, _)| { - module_extension(*sibling_language) == module_extension(language) - }) - } - - /// The runnables `content` imports, directly or through one another. - fn modules_of( - &self, - app_path: &str, - content: &str, - language: ScriptLang, - ) -> Option> { - let ext = module_extension(language)?; - let mut queue = - extract_relative_imports(content, &format!("{app_path}/app"), &Some(language)) - .unwrap_or_default(); - let mut visited = HashSet::new(); - let mut modules = BTreeMap::new(); - while let Some(path) = queue.pop() { - if !visited.insert(path.clone()) { - continue; - } - let Some((id, sibling_language, sibling_content)) = self.0.get(&path) else { - continue; - }; - if module_extension(*sibling_language) != Some(ext) { - continue; - } - queue.extend( - extract_relative_imports(sibling_content, &path, &Some(*sibling_language)) - .unwrap_or_default(), - ); - modules.insert( - format!("{id}.{ext}"), - ScriptModule { - content: sibling_content.clone(), - language: *sibling_language, - lock: None, - }, - ); - } - (!modules.is_empty()).then_some(modules) - } -} - -/// The languages whose executors resolve a relative import against a module file. -fn module_extension(language: ScriptLang) -> Option<&'static str> { - match language { - ScriptLang::Bun | ScriptLang::Bunnative => Some("ts"), - ScriptLang::Python3 => Some("py"), - _ => None, - } -} - async fn reduce_app( db: &sqlx::Pool, path: &str, @@ -2511,19 +2480,8 @@ async fn lock_modules_app( .to_string(); let mut logs = "".to_string(); - // An import satisfied by another runnable of the app is not a - // dependency on the workspace script at that path. - let referenced_paths = extract_referenced_paths( - &content, - &format!("{job_path}/app"), - Some(language), - ) - .map(|paths| { - paths - .into_iter() - .filter(|p| !raw_app_runnables.resolves(p, language)) - .collect_vec() - }); + let referenced_paths = + raw_app_runnables.referenced_paths(job_path, &content, language); // The lock covers what the imported runnables import, and nothing // records whether one of them changed, so it is never reused. let modules = @@ -2657,6 +2615,36 @@ async fn lock_modules_app( } } for (a, b) in m.clone().into_iter() { + if let Some(runnables) = raw_app_runnables_map(&a, &b, container_id.as_ref()) { + let mut locked = serde_json::Map::new(); + for (id, runnable) in runnables { + let runnable = lock_modules_app( + runnable.clone(), + job, + mem_peak, + canceled_by, + job_dir, + db, + worker_name, + worker_dir, + job_path, + base_internal_url, + token, + occupancy_metrics, + locks_to_reload, + Some(id.clone()), + dependency_map, + raw_workspace_dependencies_o, + triggered_by_relative_import, + temp_script_refs, + raw_app_runnables, + ) + .await?; + locked.insert(id.clone(), runnable); + } + m.insert(a, Value::Object(locked)); + continue; + } m.insert( a.clone(), lock_modules_app( @@ -2771,30 +2759,28 @@ pub async fn handle_app_dependency_job( .and_then(|x| x.get("temp_script_refs")) .and_then(|v| serde_json::from_str(v.get()).ok()); - // The version captured at job creation can be stale (the app may have been - // redeployed since). Relock the current latest instead, mirroring the flow - // dependency handler. - let id = if triggered_by_relative_import { - let latest_version = sqlx::query_scalar!( - "SELECT id FROM app_version WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2) ORDER BY created_at DESC LIMIT 1", - job_path, - job.workspace_id - ) - .fetch_optional(db) - .await?; - match latest_version { - Some(latest_version) if latest_version != id => { + // A relative-import relock rewrites the published version in place and deploys no new one, + // and that version was tallied by the deploy that published it. + if triggered_by_relative_import { + *deployment_tallied = true; + } + + // The version captured at job creation can be stale (the app may have been redeployed + // since): relock the one published now. + let (id, version_switched) = if triggered_by_relative_import { + match published_app_version(db, &job.workspace_id, &job_path).await? { + Some(published) if published != id => { tracing::info!( - "App version changed since dependency job was queued ({} -> {}), using latest", + "App version changed since dependency job was queued ({} -> {}), using the published one", id, - latest_version + published ); - latest_version + (published, true) } - _ => id, + _ => (id, false), } } else { - id + (id, false) }; sqlx::query!( @@ -2826,6 +2812,22 @@ pub async fn handle_app_dependency_job( // TODO: Use transaction for entire segment? if let Some((app_id, value, is_raw_app)) = record { + // The components to relock were picked in the version the job was queued for, and the + // one published since may name them differently and carry its editor's stale locks. + // Every selection that can outlive its version is resolved here, against the value + // this job writes. + let relock_all = version_switched + || components_to_relock + .as_ref() + .is_some_and(|c| c.iter().any(|c| c == RELOCK_ALL_APP_COMPONENTS)); + let components_to_relock = if relock_all { + Some(app_component_ids(&value)?) + } else if is_raw_app { + expand_legacy_raw_app_components(components_to_relock, &value) + } else { + components_to_relock + }; + let read_value = value.clone(); let raw_app_runnables = if is_raw_app { RawAppRunnables::from_value(&job_path, &value) } else { @@ -2848,7 +2850,9 @@ pub async fn handle_app_dependency_job( None, &mut dependency_map, &raw_workspace_dependencies_o, - triggered_by_relative_import, + // A relock of every component regenerates every lock: the imported lock hashes a + // skip compares are stamped even by a deploy that kept its editor's stale lock. + triggered_by_relative_import && !relock_all, &temp_script_refs, &raw_app_runnables, ) @@ -2868,15 +2872,6 @@ pub async fn handle_app_dependency_job( object.insert("version".to_string(), json!(id)); object.remove("files"); } - sqlx::query!( - "INSERT INTO app_version_lite (id, value) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value", - id, - sqlx::types::Json(to_raw_value(&value_lite)) as sqlx::types::Json>, - ) - .execute(db) - .await?; - // Re-check cancelation to ensure we don't accidentially override an app. if sqlx::query_scalar!( "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_queue WHERE id = $1", @@ -2893,27 +2888,63 @@ pub async fn handle_app_dependency_job( return Ok(()); } - sqlx::query!("UPDATE app_version SET value = $1 WHERE id = $2", value, id,) - .execute(db) - .await?; + let (deployment_message, parent_path) = + get_deployment_msg_and_parent_path_from_args(job.args.clone()); - // Re-publish the relocked version as latest for cache invalidation, but - // only if it is still the latest: the guard makes this a single atomic, - // never-demoting statement. Without it, a concurrent deploy that landed a - // newer version (e.g. same git-sync push) would be reverted, pointing a - // raw app's bundle_secret at a version with no bundle (404 / white screen). - if triggered_by_relative_import { - sqlx::query!( - "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint", - id, + let mut tx = db.begin().await?; + // Only the published version is relocked: a raw app's bundle is stored under the + // version id it was deployed with, so serving any other version 404s its bundle. + // The row lock orders this against a deploy appending a newer version. + let superseded = triggered_by_relative_import + && sqlx::query_scalar!( + "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2 FOR NO KEY UPDATE", &job_path, &job.workspace_id ) - .execute(db) - .await?; + .fetch_optional(&mut *tx) + .await? + .flatten() + != Some(id); + // Every relock of the app, and the dependency job of the deploy that published it, + // write this one row: overwriting a write made since it was read would drop its locks. + let current = sqlx::query_scalar!( + "SELECT value FROM app_version WHERE id = $1 FOR NO KEY UPDATE", + id + ) + .fetch_optional(&mut *tx) + .await?; + if superseded || current.as_ref() != Some(&read_value) { + drop(tx); + let log_msg = format!( + "\nApp version {id} was rewritten or superseded while its locks were generated; discarding them and queueing a relock of the published version" + ); + tracing::info!(workspace_id = %job.workspace_id, job_id = %job.id, "{log_msg}"); + append_logs(&job.id, &job.workspace_id, log_msg, &db.into()).await; + tally_unfinished_dependency_deploy(db, &job, deployment_tallied).await; + if current.is_some() { + // The version published when the queued relock runs may name its components + // differently than this one, and carry its editor's stale locks. + let dependent = DependencyDependent { + importer_path: job_path, + importer_kind: "app".to_string(), + importer_node_ids: Some(vec![RELOCK_ALL_APP_COMPONENTS.to_string()]), + }; + requeue_relock(db, &job, dependent, deployment_message, parent_path).await?; + } + return Ok(()); } - let (deployment_message, parent_path) = - get_deployment_msg_and_parent_path_from_args(job.args.clone()); + sqlx::query!("UPDATE app_version SET value = $1 WHERE id = $2", value, id,) + .execute(&mut *tx) + .await?; + sqlx::query!( + "INSERT INTO app_version_lite (id, value) VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value", + id, + sqlx::types::Json(to_raw_value(&value_lite)) as sqlx::types::Json>, + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; let deployed_object = if is_raw_app { DeployedObject::RawApp { path: job_path, version: id, parent_path: parent_path.clone() } @@ -3742,32 +3773,3 @@ async fn add_lock_header( Ok(()) } - -#[cfg(test)] -mod raw_app_runnables_tests { - use super::*; - - #[test] - fn modules_follow_imports_between_runnables_of_one_language() { - let value = json!({ "runnables": { - "main": { "inlineScript": { "language": "bun", "content": "import { a } from './a'" } }, - "a": { "inlineScript": { "language": "bun", "content": "import { b } from './b.ts'" } }, - "b": { "inlineScript": { "language": "bun", "content": "export const b = 1" } }, - "unused": { "inlineScript": { "language": "bun", "content": "export const u = 1" } }, - "py": { "inlineScript": { "language": "python3", "content": "x = 1" } }, - }}); - let runnables = RawAppRunnables::from_value("u/admin/app", &value); - - let modules = runnables - .modules_of( - "u/admin/app", - "import { a } from './a'\nimport { p } from './py'", - ScriptLang::Bun, - ) - .unwrap(); - assert_eq!(modules.keys().collect_vec(), vec!["a.ts", "b.ts"]); - assert!(runnables - .modules_of("u/admin/app", "export const x = 1", ScriptLang::Bun) - .is_none()); - } -} From 6d76781d4eed6d675857ab78a9652a2302df4483 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 15 Oct 2026 16:31:49 +0200 Subject: [PATCH 42/49] drop the breadcrumb navigation picker on sessions (#11793) Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/lib/components/NavBreadcrumb.svelte | 2 +- frontend/src/lib/components/pageHeaderRegistry.svelte.ts | 3 +++ frontend/src/lib/components/sessions/SessionWrapper.svelte | 6 +++++- frontend/src/routes/(root)/(logged)/sessions/+page.svelte | 4 +++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index 6eaae745bb..2169149426 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -261,7 +261,7 @@ not this component) — so the lead can run the whole line. {#snippet navPicker(measure: boolean)} - {#if !nameOnly} + {#if !nameOnly && !section?.noNavPicker} {#if measure} diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index 6098095a30..91f092e88d 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -31,6 +31,9 @@ export type PageHeaderSection = { content?: Snippet /** Scopes the segment's picker, when the section maps to a kind of item. */ kind?: WorkspaceItemKind + /** No chevron after the name: the page's siblings are not the workspace's pages and items, + * which is all that picker offers. A session is one. */ + noNavPicker?: boolean } export type PageHeaderContent = { diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index af00454f81..703a18967b 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -440,7 +440,11 @@ breadcrumb's own workspace part, the way every other route names it — a session in a fork reads like anything else in that fork. --> {/if} diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index a1b2856323..19177dc463 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -966,7 +966,9 @@ so from then on the name comes from there, with its pen and its menu. --> Date: Thu, 15 Oct 2026 16:36:38 +0200 Subject: [PATCH 43/49] fix: show the app's summary in the app viewer's page header (#11782) The viewer registered its header item with the path only, so a deployed app's summary never reached the band. It is read from the lite app endpoint the viewer already calls, which also covers sandboxed low-code apps whose content loads in an iframe. Co-authored-by: Claude Opus 5.5 (1M context) --- .../lib/components/apps/editor/InWorkspaceAppViewer.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index fe915e7ede..2f94e086ad 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -83,6 +83,8 @@ * where the lite payload carries it; the flat field beside it is the apps *list* row's * spelling (ListableApp, which is what AppRow reads). */ let executionMode = $state(undefined) + /** From the lite read too: a sandboxed low-code app never fills `app`. */ + let summary = $state(undefined) let editHref = $derived(`/${isRawApp ? 'apps_raw' : 'apps'}/edit/${path}?nodraft=true`) let refresh: (() => void) | undefined @@ -176,8 +178,10 @@ appPerms = { path: lite?.path ?? path, extraPerms: lite?.extra_perms ?? {} } isRawApp = !!lite?.raw_app executionMode = lite?.policy?.execution_mode ?? lite?.execution_mode + summary = lite?.summary || undefined } catch (_) { appPerms = undefined + summary = undefined } } @@ -267,7 +271,7 @@ no item, and the breadcrumb would fall back to the section name "Apps". --> {#if ownsPageHeader} From 3ce2f653c99b4e268dca3c2d962c128ed988643b Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 15 Oct 2026 16:38:56 +0200 Subject: [PATCH 44/49] count homepage ai session hero removals and re-pins (#11781) * feat: count homepage ai session hero removals and re-pins Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to 0253ffdd6c0b0f1ba162a43d19aed56bc7bd7a71 This commit updates the EE repository reference after PR #882 was merged in windmill-ee-private. Previous ee-repo-ref: a5d4fd2b21ffd0a6ced604c34d39a5747d9ea940 New ee-repo-ref: 0253ffdd6c0b0f1ba162a43d19aed56bc7bd7a71 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- backend/ee-repo-ref.txt | 2 +- .../src/lib/components/home/HomeAIChat.svelte | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 6a195a07d1..ec48fc9a62 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -455f98540fb042d4f624dcec681dab88b2c8df22 +0253ffdd6c0b0f1ba162a43d19aed56bc7bd7a71 diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 83e940602d..fdc76a1a00 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -50,6 +50,7 @@ import Button from '../common/button/Button.svelte' import { Badge } from '../common' import CloseButton from '../common/CloseButton.svelte' + import { logFeatureUsage } from '$lib/utils/featureUsage' import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte' import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' @@ -113,7 +114,9 @@ let pulsing = $state(false) let pulseEndTimer: ReturnType | undefined - function removeComposer() { + // `no_ai` is the overlay's button, shown only while AI is unusable here. + function removeComposer(source: 'close' | 'no_ai') { + logFeatureUsage('home', 'ai_hero_remove', { key: source }) setCollapsed(true) if (reducedMotion.val) return clearTimeout(pulseEndTimer) @@ -234,7 +237,12 @@
    - + removeComposer('close')} + />
    {/if}
    @@ -325,7 +333,9 @@ {freeTierExhausted ? 'Add your own API key' : 'Configure AI'} {/if} - +
    {/if} @@ -420,7 +430,10 @@ { displayName: 'Pin session hero to homepage', icon: WandSparkles, - action: () => setCollapsed(false) + action: () => { + logFeatureUsage('home', 'ai_hero_pin') + setCollapsed(false) + } } ] : []), From 64e1630d497af1b50a772aa66ef123f675ea6bb1 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:41:21 +0200 Subject: [PATCH 45/49] fix: let Teams handlers post with job tokens again (#11792) * fix: let job tokens post Teams messages to their own workspace's team * chore: point ee-repo-ref at the Teams job-token check * chore: update ee-repo-ref to 3b6bd2b15d9bddb0bb8f1de08b30b897db0415b4 This commit updates the EE repository reference after PR #885 was merged in windmill-ee-private. Previous ee-repo-ref: e8e4bcd63d6fc804d29e67d42ca6d4163b144a33 New ee-repo-ref: 3b6bd2b15d9bddb0bb8f1de08b30b897db0415b4 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- ...23ea068d451cf4bfd0c3c9b2f957df82173d1.json | 22 +++++++++++++++++++ backend/ee-repo-ref.txt | 2 +- backend/tests/wm_token_confinement.rs | 16 ++++++++++++++ backend/windmill-api-auth/src/scopes.rs | 6 ++++- 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 backend/.sqlx/query-8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1.json diff --git a/backend/.sqlx/query-8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1.json b/backend/.sqlx/query-8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1.json new file mode 100644 index 0000000000..5c9eca6f51 --- /dev/null +++ b/backend/.sqlx/query-8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.teams_team_id FROM v2_job j\n JOIN workspace_settings ws ON ws.workspace_id = j.workspace_id\n WHERE j.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "teams_team_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ec48fc9a62..3b37119381 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0253ffdd6c0b0f1ba162a43d19aed56bc7bd7a71 +3b6bd2b15d9bddb0bb8f1de08b30b897db0415b4 diff --git a/backend/tests/wm_token_confinement.rs b/backend/tests/wm_token_confinement.rs index e75170ec6f..e15d33354e 100644 --- a/backend/tests/wm_token_confinement.rs +++ b/backend/tests/wm_token_confinement.rs @@ -381,6 +381,22 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool) -> anyho "WM_TOKEN must still record its own tutorial progress: {}", resp.text().await? ); + // Every Teams notification a job sends posts here. An empty body is a 422 from the + // handler's extractor, so a 403 means confinement refused it. The real route is only + // mounted in an EE build. + #[cfg(all(feature = "enterprise", feature = "private"))] + { + let resp = authed(client().post(format!("{api}/teams/activities")), &user_wm) + .json(&json!({})) + .send() + .await?; + assert_eq!( + resp.status(), + 422, + "WM_TOKEN must still reach the Teams message route: {}", + resp.text().await? + ); + } // 11. ...and the caller-scoped reads deliberately left out of it, each because it // names another workspace or the identity's credentials. diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index e6108b1387..1b605e70eb 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -940,11 +940,15 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool { /// not send. /// - tutorial progress upserts a UI bitfield keyed on the caller's own email. Its path /// serves a `GET` too, which the read list above carries. -const GLOBAL_WRITES_OPEN_TO_JOB_TOKEN: [&str; 4] = [ +/// - a Teams message from a job (the Teams error, recovery and success handlers) posts as +/// the instance-wide bot. The handler refuses a job token whose conversation is not a +/// channel of the team connected to the job's workspace. +const GLOBAL_WRITES_OPEN_TO_JOB_TOKEN: [&str; 5] = [ "/api/settings/test_object_storage_config", "/api/workspaces/exists", "/api/schedules/preview", "/api/users/tutorial_progress", + "/api/teams/activities", ]; /// Confines a job token (`$WM_TOKEN`) to routes that name a workspace. It is minted From 8c0185d55a1a8c6fbc5e41b8b9d77865563f515b Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 15 Oct 2026 16:43:11 +0200 Subject: [PATCH 46/49] feat: let operators save resource and variable drafts (#11736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: operators get AI sessions `prefersSessionHandoff` stops taking the role, so every AI entry point an operator can reach — Ask AI, the home composer, Open in session, the flow editor's hand-off, the raw-app template picker — opens a session for them like anyone else. The sessions page drops its refusal, and the layout stops keeping them on the docked chat. In the sidebar they now get the Workspace/AI Sessions switch and session mode, and lose the Ask AI entry that stood in for them, since the pane behind it is no longer the one they are sent to. Co-Authored-By: Claude Opus 5 (1M context) * feat: operators get run-oriented examples in the home composer Co-Authored-By: Claude Opus 5.5 (1M context) * docs: note builder_flows in the operator composer examples comment Co-Authored-By: Claude Opus 5.5 (1M context) * feat: gate session tools and editors on operator rights Co-Authored-By: Claude Opus 5.5 (1M context) * feat: keep operator AI sessions behind a browser opt-in Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep flows refused in the prompt for operators without builder rights Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: decide every frontend edit check through one editRights module Co-Authored-By: Claude Opus 5.5 (1M context) * fix: let admins who are also operators open session editors, as drafts.rs does Co-Authored-By: Claude Opus 5.5 (1M context) * feat: let operators with manage rights save schedule and trigger drafts Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep rebase_draft from operators who can only draft schedules and triggers Co-Authored-By: Claude Opus 5.5 (1M context) * feat: let operators save resource and variable drafts Co-Authored-By: Claude Opus 5.5 (1M context) * fix: name deploy and delete tools only where a kind is left to act on Co-Authored-By: Claude Opus 5.5 (1M context) * fix: list an operator's draft-only resources and variables while they may draft them Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../tests/operator_manage_rights.rs | 110 ++++++++++++++++- backend/windmill-api-schedule/src/lib.rs | 2 +- backend/windmill-api/src/drafts.rs | 19 ++- backend/windmill-common/src/workspaces.rs | 21 ++-- backend/windmill-store/src/resources.rs | 5 +- backend/windmill-store/src/variables.rs | 5 +- backend/windmill-trigger/src/handler.rs | 2 +- docs/operator-write-rights.md | 25 ++-- .../copilot/chat/assistantPermissions.ts | 19 +-- .../components/copilot/chat/global/core.ts | 115 ++++++++++++------ .../copilot/chat/global/sessionAccess.test.ts | 24 +++- .../copilot/chat/global/sessionAccess.ts | 8 ++ .../chat/global/sessionToolset.test.ts | 14 ++- .../copilot/chat/sessionCapabilities.ts | 6 + frontend/src/lib/editRights.test.ts | 7 +- frontend/src/lib/editRights.ts | 7 +- 16 files changed, 287 insertions(+), 102 deletions(-) diff --git a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs index cfc9441dca..22901d1c87 100644 --- a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs +++ b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs @@ -293,7 +293,115 @@ async fn test_manage_rights_admit_matching_drafts(db: Pool) -> anyhow: ); let (status, body) = save("trigger_http", "u/operator/http_draft").await?; assert_eq!(status, 403, "{body}"); - assert_eq!(listed().await?, json!([])); + // Still listed, as their own, but no longer writable. + let drafts = listed().await?; + assert_eq!(drafts.as_array().map(Vec::len), Some(2), "{drafts}"); + assert!( + drafts + .as_array() + .unwrap() + .iter() + .all(|d| d["can_write"] == json!(false)), + "{drafts}" + ); + + Ok(()) +} + +/// Operators create resources and variables on their pages with no operator setting in the way, +/// so they may draft them in any workspace — even one that withdrew every manage right — and +/// deploying a secret variable draft goes through the create route like a direct write. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_operators_draft_resources_and_variables(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + invalidate_operator_rights_cache(WS); + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/{WS}"); + let c = operator_client(); + + assert_eq!( + set_settings( + &api, + json!({"manage_schedules": false, "manage_triggers": false}) + ) + .await?, + 200 + ); + + let save = async |kind: &str, path: &str, value: serde_json::Value| { + let resp = c + .post(format!("{api}/drafts/update/{kind}/{path}")) + .json(&json!({ "value": value })) + .send() + .await?; + anyhow::Ok((resp.status().as_u16(), resp.text().await?)) + }; + + let (status, body) = save( + "resource", + "u/operator/res_draft", + json!({ "path": "u/operator/res_draft", "args": {}, "resource_type": "object" }), + ) + .await?; + assert_eq!(status, 200, "{body}"); + let (status, body) = save( + "variable", + "u/operator/secret_draft", + json!({ "path": "u/operator/secret_draft", "variable": { "value": "hunter2", "is_secret": true } }), + ) + .await?; + assert_eq!(status, 200, "{body}"); + let (status, body) = save("script", "u/operator/script_draft", json!({})).await?; + assert_eq!(status, 403, "{body}"); + + // The Resources and Variables pages list a draft that has never been deployed. + for (route, path) in [ + ("resources", "u/operator/res_draft"), + ("variables", "u/operator/secret_draft"), + ] { + let listed: serde_json::Value = c + .get(format!("{api}/{route}/list?include_draft_only=true")) + .send() + .await? + .json() + .await?; + assert!( + listed + .as_array() + .unwrap() + .iter() + .any(|r| r["path"] == path && r["draft_only"] == json!(true)), + "{route}: {listed}" + ); + } + + // Deploying a draft sends its stored value, the `$encrypted:` marker, to the create route. + let stored: String = sqlx::query_scalar( + "SELECT value::jsonb->'variable'->>'value' FROM draft + WHERE workspace_id = $1 AND path = 'u/operator/secret_draft'", + ) + .bind(WS) + .fetch_one(&db) + .await?; + assert!(stored.starts_with("$encrypted:"), "{stored}"); + let resp = c + .post(format!("{api}/variables/create")) + .json(&json!({ + "path": "u/operator/secret_draft", + "value": stored, + "is_secret": true, + "description": "", + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let resp = c + .get(format!("{api}/variables/get_value/u/operator/secret_draft")) + .send() + .await?; + assert_eq!(resp.json::().await?, "hunter2"); Ok(()) } diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 646052699e..fb935e0b5c 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -930,7 +930,7 @@ async fn list_schedule( || windmill_common::workspaces::operator_can_draft( &db, &w_id, - Some(UserDraftItemKind::TriggerSchedule), + UserDraftItemKind::TriggerSchedule, ) .await?) { diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 01628e8289..b582e84e36 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -109,12 +109,6 @@ async fn list_drafts( Path(w_id): Path, Query(query): Query, ) -> Result>> { - // An operator who may draft no kind at all has no drafts of their own (see - // `require_can_write_path`), so this list is always empty for them. They can still READ some - // collaborators' drafts via `/drafts/get`. - if authed.is_operator && !operator_can_draft(&db, &w_id, None).await? { - return Ok(Json(vec![])); - } let all_users = query.all_users.unwrap_or(false); // Only honor `compare_to_workspace` when it is genuinely this workspace's // parent (a fork comparing against its source). Any other value is dropped @@ -1269,12 +1263,13 @@ pub(crate) async fn require_can_write_path( if authed.is_admin { return Ok(()); } - // Operators WRITE drafts only of a kind they could deploy: flows and full-code apps under their - // builder right, schedules and triggers under the manage rights. The kind has to be checked, or - // one right would open drafts of kinds it says nothing about. Read access is deliberately asymmetric: - // `require_can_read_path` has no operator block, so an operator can still READ a draft they - // can read via `/drafts/get`, mirroring their read access to deployed content. Intended. - if authed.is_operator && !operator_can_draft(db, w_id, Some(kind)).await? { + // Operators WRITE drafts only of a kind they could deploy: resources and variables always, + // flows and full-code apps under their builder right, schedules and triggers under the manage + // rights. The kind has to be checked, or one right would open drafts of kinds it says nothing + // about. Read access is deliberately asymmetric: `require_can_read_path` has no operator block, + // so an operator can still READ a draft they can read via `/drafts/get`, mirroring their read + // access to deployed content. Intended. + if authed.is_operator && !operator_can_draft(db, w_id, kind).await? { return Err(Error::PermissionDenied(format!( "operators cannot save {} drafts in this workspace", kind.as_str() diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index c73079bba8..3fe9dfaefd 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1365,21 +1365,26 @@ pub async fn operator_builder_rights(db: &DB, workspace_id: &str) -> Result, + kind: crate::user_drafts::UserDraftItemKind, ) -> Result { use crate::user_drafts::UserDraftItemKind; + if matches!( + kind, + UserDraftItemKind::Resource | UserDraftItemKind::Variable + ) { + return Ok(true); + } let rights = operator_rights(db, workspace_id).await?; Ok(match kind { - None => rights.builder.any() || rights.manage.schedules || rights.manage.triggers, - Some(UserDraftItemKind::Flow) => rights.builder.has(BuilderKind::Flows), - Some(UserDraftItemKind::RawApp) => rights.builder.has(BuilderKind::Apps), - Some(kind) => kind.manage_kind().is_some_and(|m| rights.manage.has(m)), + UserDraftItemKind::Flow => rights.builder.has(BuilderKind::Flows), + UserDraftItemKind::RawApp => rights.builder.has(BuilderKind::Apps), + kind => kind.manage_kind().is_some_and(|m| rights.manage.has(m)), }) } diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 19ba8c529b..0940e91d98 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -20,7 +20,7 @@ use windmill_common::per_minute_counter::PerMinuteCounter; use windmill_common::ssrf::{ is_private_ip, private_git_host_allowed, private_git_host_hint, GitRemoteCaller, }; -use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; +use windmill_common::workspaces::{check_deploy_rules, operator_can_draft, RuleCheckResult}; use crate::secret_backend_ext::rename_vault_secret; use crate::var_resource_cache::{auth_identity, cache_resource, get_cached_resource}; @@ -474,7 +474,6 @@ async fn list_resources( // list (the resources page always passes `resource_type_exclude`); they're applied // per-row below against the draft JSON's `resource_type` instead. if lq.include_draft_only.unwrap_or(false) - && !authed.is_operator && offset == 0 && lq.path_start.is_none() && lq.path.is_none() @@ -483,6 +482,8 @@ async fn list_resources( && lq.broad_filter.is_none() && lq.label.is_none() && !lq.starred_only.unwrap_or(false) + && (!authed.is_operator + || operator_can_draft(&db, &w_id, UserDraftItemKind::Resource).await?) { let rt_filter: Option> = lq .resource_type diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 151b75bd85..fca29736e6 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -11,7 +11,7 @@ use windmill_api_auth::{ ApiAuthed, }; use windmill_common::db::{Authable, DB}; -use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; +use windmill_common::workspaces::{check_deploy_rules, operator_can_draft, RuleCheckResult}; use crate::secret_backend_ext::{ delete_secret_from_backend, get_secret_value, is_external_stored_value, is_vault_stored_value, @@ -229,7 +229,6 @@ async fn list_variables( // Append the authed user's draft-only variables; see scripts.rs. if lq.include_draft_only.unwrap_or(false) - && !authed.is_operator && offset == 0 && lq.path_start.is_none() && lq.path.is_none() @@ -237,6 +236,8 @@ async fn list_variables( && lq.value.is_none() && lq.broad_filter.is_none() && lq.label.is_none() + && (!authed.is_operator + || operator_can_draft(&db, &w_id, UserDraftItemKind::Variable).await?) { let draft_only_rows = fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Variable) diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 6022e0d55f..7395a5e8a3 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -704,7 +704,7 @@ async fn list_triggers( || windmill_common::workspaces::operator_can_draft( &db, &workspace_id, - Some(T::user_draft_item_kind()), + T::user_draft_item_kind(), ) .await?) { diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md index 2cef9ef5df..a1421400f0 100644 --- a/docs/operator-write-rights.md +++ b/docs/operator-write-rights.md @@ -56,18 +56,23 @@ app rather than telling them why. Both integration tests assert the status for t ## Drafts -An operator may save a draft of a kind they could deploy, and no other: a schedule draft while -they hold `manage_schedules`, a trigger draft (every trigger kind, native ones included) while -they hold `manage_triggers`, a flow or full-code app draft under `builder_flows` / `builder_apps`. +An operator may save a draft of a kind they could deploy, and no other: a resource or variable +draft always, since no operator setting gates those routes; a schedule draft while they hold +`manage_schedules`, a trigger draft (every trigger kind, native ones included) while they hold +`manage_triggers`, a flow or full-code app draft under `builder_flows` / `builder_apps`. drafts.rs `require_can_write_path` asks `operator_can_draft`, which reads the same cached rights -as the router gate, and `UserDraftItemKind::manage_kind` maps each draft kind to its right. Resource and variable drafts -stay refused even though operators edit those items directly. The AI session chat writes -schedules and triggers only as drafts and then deploys them, so without this rule an operator who -may create a schedule on its page could not ask the chat for one. +as the router gate, and `UserDraftItemKind::manage_kind` maps each draft kind to its right. The AI +session chat writes resources, variables, schedules and triggers only as drafts and then deploys +them, so without this rule an operator who may create a schedule on its page could not ask the +chat for one. -The schedule and trigger list routes append an operator's draft-only rows under the same -`operator_can_draft` check, so a saved draft shows on its list page exactly while the operator may -still save it. +A secret variable draft is stored as an `$encrypted:` marker and decrypted by the variable create +and update routes on deploy. Those routes accept the marker from any caller, so a draft gives an +operator nothing the direct route does not. + +The schedule, trigger, resource and variable list routes append an operator's draft-only rows +under the same `operator_can_draft` check, so a saved draft shows on its list page exactly while +the operator may still save it. Deploying a draft goes through the ordinary create and update routes, so the router gate is still what decides the write. The draft rule only has to agree with it: a draft the gate would refuse to diff --git a/frontend/src/lib/components/copilot/chat/assistantPermissions.ts b/frontend/src/lib/components/copilot/chat/assistantPermissions.ts index 4df3f605e2..d00ac65b8e 100644 --- a/frontend/src/lib/components/copilot/chat/assistantPermissions.ts +++ b/frontend/src/lib/components/copilot/chat/assistantPermissions.ts @@ -11,8 +11,7 @@ export const OPERATOR_SETTINGS_LOCATION = 'Workspace settings → Users → Oper type PermissionRow = { kind: EditKind label: string - /** The draft capability the kind's write tool declares in `requires`, plus the schedule or - * trigger write gate where the kind has one. */ + /** The draft capability the kind's write tool declares in `requires`. */ assistant: (access: SessionAccess) => boolean } @@ -20,18 +19,10 @@ const ROWS: PermissionRow[] = [ { kind: 'script', label: 'Scripts', assistant: (a) => a.has('write_draft') }, { kind: 'flow', label: 'Flows', assistant: (a) => a.has('write_flow_draft') }, { kind: 'raw_app', label: 'Apps', assistant: (a) => a.has('write_app_draft') }, - { - kind: 'schedule', - label: 'Schedules', - assistant: (a) => a.has('write_draft') && a.has('manage_schedules') - }, - { - kind: 'trigger', - label: 'Triggers', - assistant: (a) => a.has('write_draft') && a.has('manage_triggers') - }, - { kind: 'resource', label: 'Resources', assistant: (a) => a.has('write_draft') }, - { kind: 'variable', label: 'Variables', assistant: (a) => a.has('write_draft') } + { kind: 'schedule', label: 'Schedules', assistant: (a) => a.has('write_schedule_draft') }, + { kind: 'trigger', label: 'Triggers', assistant: (a) => a.has('write_trigger_draft') }, + { kind: 'resource', label: 'Resources', assistant: (a) => a.has('write_resource_draft') }, + { kind: 'variable', label: 'Variables', assistant: (a) => a.has('write_variable_draft') } ] export type RoleName = 'admin' | 'developer' | 'operator' diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index fc28f6cd5b..3b6064dee6 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -236,12 +236,13 @@ import { WRITE_FLOW_DRAFT, WRITE_SCHEDULE_DRAFT, WRITE_TRIGGER_DRAFT, + WRITE_RESOURCE_DRAFT, + WRITE_VARIABLE_DRAFT, type SessionAccess, type SessionTool, type SessionToolPolicy } from '../sessionCapabilities' import { OPERATOR_SETTINGS_LOCATION } from '../assistantPermissions' -import { capitalize } from '$lib/utils/capitalize' import { buildRunsUrl, buildSchedulesUrl, @@ -1459,8 +1460,8 @@ function buildFolderGuidance( return lines.join('\n') } -const listJoin = (items: (string | false)[]) => - new Intl.ListFormat('en', { type: 'conjunction' }).format(items.filter((i): i is string => !!i)) +const listJoin = (items: (string | false)[], type: 'conjunction' | 'disjunction' = 'conjunction') => + new Intl.ListFormat('en', { type }).format(items.filter((i): i is string => !!i)) const buildGlobalSystemPrompt = ( username: string, @@ -1477,25 +1478,47 @@ const buildGlobalSystemPrompt = ( const canWriteAppDraft = !access || access.has('write_app_draft') const canDraftSchedules = !access || access.has('write_schedule_draft') const canDraftTriggers = !access || access.has('write_trigger_draft') + const canDraftResources = !access || access.has('write_resource_draft') + const canDraftVariables = !access || access.has('write_variable_draft') // A builder operator: drafts of the kinds the workspace lets them compose, and nothing else. // Blocks about drafting in general follow `canWriteAnyDraft`; blocks naming scripts or the // other kinds keep `canWriteDraft`. const buildsFlows = canWriteFlowDraft && !canWriteDraft const buildsApps = canWriteAppDraft && !canWriteDraft const canWriteAnyDraft = - canWriteDraft || canWriteFlowDraft || canWriteAppDraft || canDraftSchedules || canDraftTriggers - // Both read only where `write_draft` is missing: it implies the two draft rights. - const draftedOperatorKinds = [canDraftSchedules && 'schedules', canDraftTriggers && 'triggers'] - .filter((k): k is string => !!k) - .join(' and ') + canWriteDraft || + canWriteFlowDraft || + canWriteAppDraft || + canDraftSchedules || + canDraftTriggers || + canDraftResources || + canDraftVariables + const canDeploy = !access || access.has('deploy') + // Whether deploy_workspace_item and delete_workspace_item keep any kind: only schedules and + // triggers skip the deploy rules, and an admin passes them. + const canDeployAnyKind = + canDeploy || access.has('manage_schedules') || access.has('manage_triggers') + // These read only where `write_draft` is missing: it implies the other draft rights. + const draftedKinds = [ + canDraftResources && 'resources', + canDraftVariables && 'variables', + canDraftSchedules && 'schedules', + canDraftTriggers && 'triggers' + ].filter((k): k is string => !!k) + const draftedOperatorKinds = listJoin(draftedKinds) + // Resources and variables deploy under the rules; schedules and triggers under the right + // that also lets them be drafted. + const deployableKinds = draftedKinds.filter( + (k) => canDeploy || k === 'schedules' || k === 'triggers' + ) const refusedKinds = [ !buildsFlows && 'flows', 'scripts', !buildsApps && 'apps', !canDraftSchedules && 'schedules', !canDraftTriggers && 'triggers', - 'resources', - 'variables' + !canDraftResources && 'resources', + !canDraftVariables && 'variables' ].filter((k): k is string => !!k) const refusedCode = ['scripts', !buildsFlows && 'flows', !buildsApps && 'apps'].filter( (k): k is string => !!k @@ -1555,7 +1578,7 @@ The current user's workspace username is "${username}".${instanceLine} ${ canWriteDraft ? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.' - : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${refusedKinds.slice(0, -1).join(', ')} or ${refusedKinds.at(-1)} here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin, who grants operators more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor. ${capitalize(listJoin(['resources', 'variables', canManageSchedules && !canDraftSchedules && 'schedules', canManageTriggers && !canDraftTriggers && 'triggers']))} they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when( + : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${listJoin(refusedKinds, 'disjunction')} here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${listJoin(refusedCode)} outside this chat too, so for those suggest asking a workspace admin, who grants operators more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor.${when( canManageSchedules, ' To make something recur, they can schedule a deployed script or flow themselves; only when none does the job is there something to ask an admin for, and it is that script.' )} When you decline something, give the reason stated here and keep to it.${when( @@ -1566,13 +1589,19 @@ ${ `\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.` )}${when( !!draftedOperatorKinds, - `\n\nThis workspace lets them create and edit ${draftedOperatorKinds}: write them as drafts with ${[ - canDraftSchedules && 'write_schedule', - canDraftTriggers && - 'write_trigger (call get_trigger_schema first: the config fields differ per kind)' - ] - .filter(Boolean) - .join(' and ')}, then deploy them with deploy_workspace_item once the user asks.` + `\n\nThis workspace lets them create and edit ${draftedOperatorKinds}: write them as drafts with ${listJoin( + [ + canDraftResources && 'write_resource (call search_resource_types first)', + canDraftVariables && 'write_variable', + canDraftSchedules && 'write_schedule', + canDraftTriggers && + 'write_trigger (call get_trigger_schema first: the config fields differ per kind)' + ] + )}${ + deployableKinds.length + ? `, then deploy ${deployableKinds.length === draftedKinds.length ? 'them' : listJoin(deployableKinds)} with deploy_workspace_item once the user asks` + : '' + }.` )}` }${when( canWriteAnyDraft, @@ -1600,10 +1629,16 @@ Rules:${when( - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. - Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed. - If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when( + canWriteAnyDraft && canDeployAnyKind, + ` +- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.` + )}${when( canWriteAnyDraft, ` -- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. -- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.` +- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft).${when( + canDeployAnyKind, + ' Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.' + )}` )}${when( !canWriteAnyDraft, ` @@ -1620,9 +1655,12 @@ Rules:${when( }, here or in the editor, so never ask them to create or edit one: suggest asking a workspace admin.` )} - Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.${when( + canDraftVariables, + ` +- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value.` + )}${when( canWriteDraft, ` -- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value. - Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition. - When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it. - Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub///\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing. @@ -2748,13 +2786,17 @@ export function getSessionContextPromptSection( access.has('write_flow_draft') || access.has('write_app_draft') || access.has('write_schedule_draft') || - access.has('write_trigger_draft') + access.has('write_trigger_draft') || + access.has('write_resource_draft') || + access.has('write_variable_draft') const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview') // The kinds no deploy rule reaches, unless the workspace withdrew them from this user. const unruledKinds = [ (!access || access.has('manage_schedules')) && 'schedules', (!access || access.has('manage_triggers')) && 'triggers' ].filter((k): k is string => !!k) + // With no kind left to deploy, the deploy tool is not shipped, so the lines below cannot name it. + const deployer = canDeploy || unruledKinds.length ? 'deploy_workspace_item' : 'A deploy' const targets = ['reads', canWriteDraft && 'drafts', canRunPreview && 'test runs', 'deploys'] .filter(Boolean) .join(', ') @@ -2770,19 +2812,19 @@ export function getSessionContextPromptSection( ) } else if (ctx.parentWorkspaceId && ctx.isDevWorkspace) { lines.push( - `- Operating workspace: "${ctx.workspaceId}" — the user's persistent DEV WORKSPACE, forked from workspace "${ctx.parentWorkspaceId}". deploy_workspace_item publishes into the dev workspace only; the user reviews & promotes changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".` + `- Operating workspace: "${ctx.workspaceId}" — the user's persistent DEV WORKSPACE, forked from workspace "${ctx.parentWorkspaceId}". ${deployer} publishes into the dev workspace only; the user reviews & promotes changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".` ) } else if (ctx.parentWorkspaceId) { lines.push( - `- Operating workspace: "${ctx.workspaceId}" — an ephemeral STAGED FORK of workspace "${ctx.parentWorkspaceId}", created for session work. deploy_workspace_item publishes into the fork only, and the user reviews & promotes fork changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".` + `- Operating workspace: "${ctx.workspaceId}" — an ephemeral STAGED FORK of workspace "${ctx.parentWorkspaceId}", created for session work. ${deployer} publishes into the fork only, and the user reviews & promotes fork changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".` ) } else if (ctx.forkParentUnknown) { lines.push( - `- Operating workspace: "${ctx.workspaceId}" — a fork whose parent workspace is not currently visible to this user. deploy_workspace_item publishes into the fork only; the user promotes changes from the session's deploy panel. Never present a change as live in any other workspace.` + `- Operating workspace: "${ctx.workspaceId}" — a fork whose parent workspace is not currently visible to this user. ${deployer} publishes into the fork only; the user promotes changes from the session's deploy panel. Never present a change as live in any other workspace.` ) } else if (ctx.workspaceId) { lines.push( - `- Operating workspace: "${ctx.workspaceId}" — the live workspace itself, not a fork. deploy_workspace_item publishes directly to everyone in it.` + `- Operating workspace: "${ctx.workspaceId}" — the live workspace itself, not a fork. ${deployer} publishes directly to everyone in it.` ) } else { lines.push( @@ -3656,15 +3698,11 @@ export const globalTools: SessionTool<{}>[] = [ !access || access.has('write_draft') || (parsed.subject === 'flow' && access.has('write_flow_draft')) || - (parsed.subject === 'app' && access.has('write_app_draft')) + (parsed.subject === 'app' && access.has('write_app_draft')) || + (parsed.subject === 'resource' && access.has('write_resource_draft')) if (!canAuthor) { - const composed = [ - access?.has('write_flow_draft') && 'flow', - access?.has('write_app_draft') && 'app' - ].filter((k): k is string => !!k) - const message = composed.length - ? `This session can only compose ${composed.map((k) => `${k}s`).join(' and ')}, so ${composed.join(' and ')} ${composed.length > 1 ? 'are the subjects' : 'is the one subject'} with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.` - : 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.' + const message = + 'This session cannot create or edit this kind of item, so there is no authoring guidance to give for it. Tell the user plainly rather than describing how it would be done.' toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' }) return message } @@ -4417,8 +4455,9 @@ export const globalTools: SessionTool<{}>[] = [ app: WRITE_APP_DRAFT, resource: WRITE_DRAFT, variable: WRITE_DRAFT, - // Not the schedule/trigger draft rights: `rebaseDraft` has no handler for these kinds, - // so an operator holding only those rights would be offered a tool that always throws. + // Not the per-kind draft rights: `rebaseDraft` has no handler for resources, variables, + // schedules or triggers, so an operator holding only those rights would be offered a + // tool that always throws. schedule: WRITE_DRAFT, trigger: WRITE_DRAFT } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, @@ -4509,7 +4548,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_RESOURCE_DRAFT, def: createToolDef( writeResourceSchema, 'write_resource', @@ -4525,7 +4564,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_VARIABLE_DRAFT, def: createToolDef( writeVariableSchema, 'write_variable', diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts index 41f6af2d5c..38718ed1b4 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -57,8 +57,10 @@ describe('resolveSessionAccess', () => { 'write_app_draft', 'write_draft', 'write_flow_draft', + 'write_resource_draft', 'write_schedule_draft', - 'write_trigger_draft' + 'write_trigger_draft', + 'write_variable_draft' ]) }) @@ -70,8 +72,10 @@ describe('resolveSessionAccess', () => { 'deploy', 'manage_schedules', 'manage_triggers', + 'write_resource_draft', 'write_schedule_draft', - 'write_trigger_draft' + 'write_trigger_draft', + 'write_variable_draft' ]) }) @@ -81,8 +85,10 @@ describe('resolveSessionAccess', () => { expect([...caps].sort()).toEqual([ 'manage_schedules', 'manage_triggers', + 'write_resource_draft', 'write_schedule_draft', - 'write_trigger_draft' + 'write_trigger_draft', + 'write_variable_draft' ]) }) @@ -100,8 +106,10 @@ describe('resolveSessionAccess', () => { 'write_app_draft', 'write_draft', 'write_flow_draft', + 'write_resource_draft', 'write_schedule_draft', - 'write_trigger_draft' + 'write_trigger_draft', + 'write_variable_draft' ]) } ) @@ -133,8 +141,10 @@ describe('resolveSessionAccess', () => { 'manage_triggers', 'run_flow_preview', 'write_flow_draft', + 'write_resource_draft', 'write_schedule_draft', - 'write_trigger_draft' + 'write_trigger_draft', + 'write_variable_draft' ]) }) @@ -152,8 +162,10 @@ describe('resolveSessionAccess', () => { 'manage_schedules', 'manage_triggers', 'write_app_draft', + 'write_resource_draft', 'write_schedule_draft', - 'write_trigger_draft' + 'write_trigger_draft', + 'write_variable_draft' ]) }) diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index 0999de617b..86239f5b19 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -17,6 +17,8 @@ const ALL_CAPABILITIES: SessionCapability[] = [ 'write_app_draft', 'write_schedule_draft', 'write_trigger_draft', + 'write_resource_draft', + 'write_variable_draft', 'run_preview', 'run_flow_preview', 'deploy', @@ -67,6 +69,12 @@ export function capabilitiesForRole(role: { if (roleCanDraft('trigger', user, role.operatorSettings)) { capabilities.add('write_trigger_draft') } + if (roleCanDraft('resource', user, role.operatorSettings)) { + capabilities.add('write_resource_draft') + } + if (roleCanDraft('variable', user, role.operatorSettings)) { + capabilities.add('write_variable_draft') + } if (authorsCode) { capabilities.add('run_preview') capabilities.add('manage_code') diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts index 5ef973f5fd..d3f5bfbb05 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -54,9 +54,13 @@ function sessionReachableTools(): SessionTool[] { } function withheldNames(access: SessionAccess): string[] { + // Shipped, not merely allowed: a kind-taking tool left with no kind is dropped too. + const shipped = new Set( + filterSessionTools(sessionReachableTools(), access).map((t) => t.def.function.name) + ) return sessionReachableTools() - .filter((t) => !sessionToolAllowed(t, access)) .map((t) => t.def.function.name) + .filter((n) => !shipped.has(n)) } /** The tools a session actually ships, through the same assembly production uses. */ @@ -313,9 +317,11 @@ describe('session tool policies', () => { }) }).content as string - // Schedules and triggers are not sent to the UI: the chat drafts them itself. - expect(prompt({})).toContain('Resources and variables they can still edit') - expect(prompt({})).toContain('lets them create and edit schedules and triggers') + // None of these is sent to the UI: the chat drafts them itself. + expect(prompt({})).not.toContain('they can still edit themselves on their pages') + expect(prompt({})).toContain( + 'lets them create and edit resources, variables, schedules, and triggers' + ) expect(prompt({})).toContain('schedule a deployed script or flow themselves') expect(prompt({ manage_schedules: false })).not.toContain('schedule a deployed script') }) diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts index bc6f8f5167..b06c41850e 100644 --- a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -23,6 +23,10 @@ export type SessionCapability = | 'write_schedule_draft' /** The same for a trigger draft, read from `manage_triggers`. */ | 'write_trigger_draft' + /** Passes `require_can_write_path` for a resource draft, which every operator does. */ + | 'write_resource_draft' + /** The same for a variable draft. */ + | 'write_variable_draft' /** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ | 'run_preview' /** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an @@ -63,6 +67,8 @@ export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft'] export const WRITE_APP_DRAFT: SessionToolPolicy = ['write_app_draft'] export const WRITE_SCHEDULE_DRAFT: SessionToolPolicy = ['write_schedule_draft'] export const WRITE_TRIGGER_DRAFT: SessionToolPolicy = ['write_trigger_draft'] +export const WRITE_RESOURCE_DRAFT: SessionToolPolicy = ['write_resource_draft'] +export const WRITE_VARIABLE_DRAFT: SessionToolPolicy = ['write_variable_draft'] export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview'] export const DEPLOY: SessionToolPolicy = ['deploy'] diff --git a/frontend/src/lib/editRights.test.ts b/frontend/src/lib/editRights.test.ts index 978210d530..a6d36f693c 100644 --- a/frontend/src/lib/editRights.test.ts +++ b/frontend/src/lib/editRights.test.ts @@ -43,7 +43,12 @@ describe('edit rights', () => { expect(roleCanDraft('trigger', user({}), undefined)).toBe(true) expect(roleCanDraft('schedule', user({}), { manage_schedules: false })).toBe(false) expect(roleCanDraft('trigger', user({}), { manage_triggers: false })).toBe(false) - expect(roleCanDraft('resource', user({}), undefined)).toBe(false) + }) + + it('lets an operator draft resources and variables, but not scripts', () => { + expect(roleCanDraft('resource', user({}), undefined)).toBe(true) + expect(roleCanDraft('variable', user({}), undefined)).toBe(true) + expect(roleCanDraft('script', user({}), undefined)).toBe(false) }) // The role alone is not enough: a builder still cannot edit a flow in a folder they only read. diff --git a/frontend/src/lib/editRights.ts b/frontend/src/lib/editRights.ts index 02c266280d..888b61e818 100644 --- a/frontend/src/lib/editRights.ts +++ b/frontend/src/lib/editRights.ts @@ -72,8 +72,8 @@ export function roleCanAuthor( * Whether the user may save drafts of `kind` — what every session editor and the chat's draft * tools write. Mirrors drafts.rs `require_can_write_path`, which differs from the item handlers * both ways: it admits an admin before its operator branch, and an operator drafts only the kinds - * they could deploy — flows and full-code apps under their builder right, schedules and triggers - * under the manage rights — never resources, variables or groups, which they write directly. + * they could deploy — resources and variables always, flows and full-code apps under their builder + * right, schedules and triggers under the manage rights. */ export function roleCanDraft( kind: EditKind, @@ -88,6 +88,9 @@ export function roleCanDraft( case 'schedule': case 'trigger': return roleCanAuthor(kind, user, operatorSettings) + case 'resource': + case 'variable': + return true default: return false } From f9513e82eed3721ecbfd87004d1cd385ac740903 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 15 Oct 2026 16:49:50 +0200 Subject: [PATCH 47/49] feat: run duckdb jobs out of process under job isolation, drop the engine fork, bump to 1.5.6 (#11780) * feat: prototype running duckdb jobs out of process inside nsjail Co-Authored-By: Claude Opus 5.5 * fix: bound runner output, honor force install outside the jail, set oom score before exec Co-Authored-By: Claude Opus 5.5 * fix: gate the duckdb runner bin behind a feature and decode pre-install errors Co-Authored-By: Claude Opus 5.5 * feat: allowlist-only policy inside the duckdb jail, with its own extension cache Co-Authored-By: Claude Opus 5.5 * feat: run duckdb jobs out of process under job isolation and drop the engine fork Co-Authored-By: Claude Opus 5.5 * feat: runner everywhere, stock duckdb engine, no sql fence Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 24bdc6615b3f651141fb740e2cacf9c3c2d81252 This commit updates the EE repository reference after PR #884 was merged in windmill-ee-private. Previous ee-repo-ref: 73452bbc280cc9ca33cf0fe325d26c2fbf3aace4 New ee-repo-ref: 24bdc6615b3f651141fb740e2cacf9c3c2d81252 Automated by sync-ee-ref workflow. * fix: pass the windows system env and TZ to the duckdb runner Co-Authored-By: Claude Opus 5.5 * feat: cap a jailed duckdb job's disk use from the worker, 6GiB by default on cloud Co-Authored-By: Claude Opus 5.5 * fix: keep a disk-backed jail tmp under the duckdb disk cap Co-Authored-By: Claude Opus 5.5 * chore: drop the benchmark scaffolding and bump duckdb to 1.5.6 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- .github/workflows/backend-test-windows.yml | 4 +- .github/workflows/build-publish-rh-image.yml | 12 +- .github/workflows/build-publish-rh8-image.yml | 4 +- .github/workflows/build_windows_worker_.yml | 4 +- .github/workflows/docker-image.yml | 2 +- .github/workflows/publish_windows_worker.yml | 4 +- Dockerfile | 6 +- backend/.gitignore | 2 +- backend/AGENTS.md | 18 +- backend/Cargo.lock | 1 - backend/Cargo.toml | 1 - .../windmill-duckdb-ffi-internal/Cargo.lock | 31 +- .../windmill-duckdb-ffi-internal/Cargo.toml | 12 +- .../README_DEV.md | 15 +- .../windmill-duckdb-ffi-internal/build_dev.sh | 21 +- .../src/bin/windmill_duckdb_runner.rs | 122 ++++ .../windmill-duckdb-ffi-internal/src/lib.rs | 538 ++------------- backend/windmill-worker/Cargo.toml | 3 +- .../nsjail/run.duckdb.config.proto | 145 ++++ .../windmill-worker/src/duckdb_executor.rs | 466 ++++--------- backend/windmill-worker/src/duckdb_runner.rs | 626 ++++++++++++++++++ backend/windmill-worker/src/lib.rs | 4 +- docker/RHEL8/Dockerfile | 2 +- docker/RHEL9/Dockerfile | 2 +- docs/duckdb-execution.md | 118 ++++ docs/duckdb-isolation.md | 223 ------- docs/external-instance-datatables.md | 4 +- 27 files changed, 1266 insertions(+), 1124 deletions(-) create mode 100644 backend/windmill-duckdb-ffi-internal/src/bin/windmill_duckdb_runner.rs create mode 100644 backend/windmill-worker/nsjail/run.duckdb.config.proto create mode 100644 backend/windmill-worker/src/duckdb_runner.rs create mode 100644 docs/duckdb-execution.md delete mode 100644 docs/duckdb-isolation.md diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index 3468449753..9fa13bca58 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -139,8 +139,8 @@ jobs: run: | cargo build --release -p windmill_duckdb_ffi_internal New-Item -ItemType Directory -Path ..\target\debug -Force - Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\ - # duckdb is bundled (~2GB of build artifacts); the DLL is the only + Copy-Item target\release\windmill_duckdb_runner.exe ..\target\debug\ + # duckdb is bundled (~2GB of build artifacts); the runner is the only # thing we need from this excluded-crate target dir. Remove-Item -Recurse -Force -ErrorAction SilentlyContinue target Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize diff --git a/.github/workflows/build-publish-rh-image.yml b/.github/workflows/build-publish-rh-image.yml index dcb223b534..f037e7eb75 100644 --- a/.github/workflows/build-publish-rh-image.yml +++ b/.github/workflows/build-publish-rh-image.yml @@ -83,17 +83,17 @@ jobs: # Extract arm64 binary (include deps/ for hard link resolution) mkdir -p /tmp/arm64 crane export --platform linux/arm64 ${{ steps.meta-ee-public.outputs.tags }} - \ - | tar -xf - -C /tmp/arm64 windmill/target/release/ usr/src/app/libwindmill_duckdb_ffi_internal.so + | tar -xf - -C /tmp/arm64 windmill/target/release/ usr/src/app/windmill_duckdb_runner cp /tmp/arm64/windmill/target/release/windmill extracted/windmill-ee-arm64-rhel9 - cp /tmp/arm64/usr/src/app/libwindmill_duckdb_ffi_internal.so extracted/libwindmill_duckdb_ffi_internal-arm64.so + cp /tmp/arm64/usr/src/app/windmill_duckdb_runner extracted/windmill_duckdb_runner-arm64 rm -rf /tmp/arm64 # Extract amd64 binary mkdir -p /tmp/amd64 crane export --platform linux/amd64 ${{ steps.meta-ee-public.outputs.tags }} - \ - | tar -xf - -C /tmp/amd64 windmill/target/release/ usr/src/app/libwindmill_duckdb_ffi_internal.so + | tar -xf - -C /tmp/amd64 windmill/target/release/ usr/src/app/windmill_duckdb_runner cp /tmp/amd64/windmill/target/release/windmill extracted/windmill-ee-amd64-rhel9 - cp /tmp/amd64/usr/src/app/libwindmill_duckdb_ffi_internal.so extracted/libwindmill_duckdb_ffi_internal-amd64.so + cp /tmp/amd64/usr/src/app/windmill_duckdb_runner extracted/windmill_duckdb_runner-amd64 rm -rf /tmp/amd64 - uses: actions/upload-artifact@v4 @@ -109,9 +109,9 @@ jobs: - uses: actions/upload-artifact@v4 with: name: RHEL9-arm64 dynamic libraries build - path: extracted/libwindmill_duckdb_ffi_internal-arm64.so + path: extracted/windmill_duckdb_runner-arm64 - uses: actions/upload-artifact@v4 with: name: RHEL9-amd64 dynamic libraries build - path: extracted/libwindmill_duckdb_ffi_internal-amd64.so + path: extracted/windmill_duckdb_runner-amd64 diff --git a/.github/workflows/build-publish-rh8-image.yml b/.github/workflows/build-publish-rh8-image.yml index 7c820eb290..83b12d3290 100644 --- a/.github/workflows/build-publish-rh8-image.yml +++ b/.github/workflows/build-publish-rh8-image.yml @@ -103,7 +103,7 @@ jobs: id: extract-duckdb-ffi-internal with: image: ${{ steps.meta-ee-public.outputs.tags}}-amd64 - path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so" + path: "/usr/src/app/windmill_duckdb_runner" # - uses: shrink/actions-docker-extract@v3 # id: extract-ee-arm64 @@ -124,7 +124,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: RHEL8-amd64 dynamic libraries build - path: ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/libwindmill_duckdb_ffi_internal.so + path: ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/windmill_duckdb_runner # - uses: actions/upload-artifact@v4 # with: diff --git a/.github/workflows/build_windows_worker_.yml b/.github/workflows/build_windows_worker_.yml index 46befe42b6..4a6368ee1e 100644 --- a/.github/workflows/build_windows_worker_.yml +++ b/.github/workflows/build_windows_worker_.yml @@ -81,5 +81,5 @@ jobs: - name: Upload dynamic libraries artifact uses: actions/upload-artifact@v4 with: - name: windmill_duckdb_ffi_internal.dll - path: ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_ffi_internal.dll + name: windmill_duckdb_runner.exe + path: ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner.exe diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c94521d332..7e05675495 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -218,7 +218,7 @@ jobs: id: extract-duckdb-ffi-internal with: image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} - path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so" + path: "/usr/src/app/windmill_duckdb_runner" - uses: shrink/actions-docker-extract@v3 id: extract-ee diff --git a/.github/workflows/publish_windows_worker.yml b/.github/workflows/publish_windows_worker.yml index b94d5d57c7..ecceff03ab 100644 --- a/.github/workflows/publish_windows_worker.yml +++ b/.github/workflows/publish_windows_worker.yml @@ -72,8 +72,8 @@ jobs: files: | ./backend/target/release/windmill-ee.exe - - name: Attach dynamic libraries to release + - name: Attach the DuckDB runner to release uses: softprops/action-gh-release@v2 with: files: | - ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_ffi_internal.dll + ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner.exe diff --git a/Dockerfile b/Dockerfile index 4e022d4b53..80de6d4d16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,10 +50,7 @@ RUN apt-get update && apt-get install -y clang=1:19.0* libclang-dev=1:19.0* cmak COPY ./backend/windmill-duckdb-ffi-internal . -# The `duckdb` crate comes from a git dependency (a fork carrying an engine patch), -# which cargo checks out under $CARGO_HOME/git rather than the registry cache. RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=$SCCACHE_DIR,sharing=locked \ cargo build --release -p windmill_duckdb_ffi_internal @@ -286,7 +283,8 @@ ENV TZ=Etc/UTC COPY --from=builder /frontend/build /static_frontend COPY --from=builder /windmill/target/release/windmill ${APP}/windmill -COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/target/release/libwindmill_duckdb_ffi_internal.so ${APP}/libwindmill_duckdb_ffi_internal.so +# DuckDB jobs run in this binary, which the worker looks for next to its own. +COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner ${APP}/windmill_duckdb_runner COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun diff --git a/backend/.gitignore b/backend/.gitignore index 6645e22618..78639e3b4e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -10,4 +10,4 @@ windmill-api/openapi-*.* *ee.rs generate_mcp_endpoints_tools/venv bacon.toml -libwindmill_duckdb_ffi_internal.so \ No newline at end of file +windmill_duckdb_runner \ No newline at end of file diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fbacb857ee..290890009a 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -6,17 +6,17 @@ - **DB schema**: `backend/summarized_schema.txt` - **API routes entry point**: `windmill-api/src/lib.rs` - **OpenAPI spec**: `windmill-api/openapi.yaml` -- **DuckDB local jobs**: build the dynamic FFI library before running DuckDB scripts locally: +- **DuckDB local jobs**: build the DuckDB runner before running DuckDB scripts locally: ```bash cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh ``` - Re-run after clean builds or when `target/debug/libwindmill_duckdb_ffi_internal.*` is missing. + Re-run after clean builds or when `target/debug/windmill_duckdb_runner` is missing. The bundled DuckDB compile (~2min) is cached in a per-user dir shared across worktrees (keyed by the crate's `Cargo.lock`), so a fresh worktree reuses it and the build is near-instant — you don't pay the full compile per worktree. Editing - the FFI crate's own source falls back to an isolated per-worktree `./target`. - The engine is a **patched fork** of duckdb-rs, not the crates.io crate — read - `docs/duckdb-isolation.md` before bumping it or touching the isolation transform. + the crate's own source falls back to an isolated per-worktree `./target`. + Jobs run in that binary, out of the worker's process, under the worker's job isolation — + read `docs/duckdb-execution.md` before changing how it is spawned or what crosses to it. - **Running data pipelines (DuckLake) from source**: see the section below — a plain build advertises the `duckdb` tag but cannot execute DuckDB scripts and has no working S3 proxy. @@ -120,7 +120,7 @@ only what you need — build time scales with the set. | `enterprise` | EE business logic (autoscaling, SAML hooks, advanced S3 rule **enforcement**, WAP, forks, …). Pulls in `license`. | Running EE features. Advanced S3 permission rules only take effect with this. | | `license` | License-key/plan plumbing (`LICENSE_KEY`). Pulled in by `enterprise`. Having the feature compiled does **not** require a license *key* at runtime — CE defaults to a free plan and most EE paths still run keyless. | License-gated behavior. | | `parquet` | S3/object-storage support: the `job_helpers/*` and `apps_u/*` S3 endpoints, parquet/CSV preview, workspace large-file storage. Without it those routes return `"requires parquet"`. | Anything touching S3/object storage or datasets. | -| `duckdb` | DuckDB script executor (also needs the FFI dylib — see above). | DuckDB scripts, DuckLake. | +| `duckdb` | DuckDB script executor (also needs the runner binary — see above). | DuckDB scripts, DuckLake. | | `python` `rust` `php` `java` `ruby` `csharp` `nu` `deno_core` `mysql` `mssql` `bigquery` `snowflake` `oracledb` `rlang` | Each enables that language/DB runtime for job execution. | Running jobs in that language. | | `mcp` | MCP gateway routes (baseline `quickjs` does NOT include it → MCP routes 404). | MCP work. | | `websocket` `http_trigger` `kafka` `nats` `mqtt_trigger` `sqs_trigger` `gcp_trigger` `azure_trigger` `postgres_trigger` `native_trigger` | Each native trigger kind; none on by default (creating one 404s without its feature). | Working on / exercising that trigger. | @@ -136,7 +136,7 @@ minimal explicit set for dev. | Plain dev baseline (JS eval only) | `quickjs` | | S3 / object storage / datasets (CE) | `quickjs,private,parquet` | | S3 + EE (advanced S3 rules, on-behalf app reads, WAP, forks) | `quickjs,enterprise,private,parquet` | -| DuckLake / DuckDB (CE) | `quickjs,duckdb,parquet,private` (+ build the FFI) | +| DuckLake / DuckDB (CE) | `quickjs,duckdb,parquet,private` (+ build the runner) | | + Python jobs | append `,python` | ## Workspace object storage in dev — use the local filesystem @@ -163,7 +163,7 @@ in DuckDB"`) — DuckLake/datatable go through the S3 proxy instead, which works ## Running data pipelines (DuckLake) from source -DuckLake pipelines need **both** the right cargo features **and** the prebuilt DuckDB FFI. A +DuckLake pipelines need **both** the right cargo features **and** the DuckDB runner binary. A plain `cargo run` (or `cargo run --features quickjs`) does **not** suffice, and the failure modes are silent-ish, so agents lose time. Verify feature names against `backend/Cargo.toml` `[features]`. @@ -178,7 +178,7 @@ are silent-ish, so agents lose time. Verify feature names against `backend/Cargo `enterprise` already pulls in `license`, but list both when you want the license-gated paths. `quickjs` is for JS eval, not DuckLake per se — keep it if your baseline build had it. -**Before running any DuckDB script**, build the FFI (see the bullet above): +**Before running any DuckDB script**, build the runner (see the bullet above): `cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh`. **Two gotchas that a wrong feature set produces:** diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b2139d6d41..bc5b9aa55c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17465,7 +17465,6 @@ dependencies = [ "jsonwebtoken 10.4.0", "lazy_static", "libffi-sys", - "libloading", "mappable-rc", "mime_guess", "mysql_async", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 24955a3744..6fc1341d53 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -723,7 +723,6 @@ flume = { version = "0.11.1", features = ["async"] } kube = { version = "1.1.0", features = ["runtime", "derive"] } schemars = "0.8" k8s-openapi = { version = "0.25.0", features = ["latest"] } -libloading = "0.8.8" # Macro-related proc-macro2 = "1.0" diff --git a/backend/windmill-duckdb-ffi-internal/Cargo.lock b/backend/windmill-duckdb-ffi-internal/Cargo.lock index f92bb6fda1..74cd7b8a18 100644 --- a/backend/windmill-duckdb-ffi-internal/Cargo.lock +++ b/backend/windmill-duckdb-ffi-internal/Cargo.lock @@ -460,19 +460,11 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "diffy" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10aec8f7f9393bd6a4f2762be0ceb012d3cbe2478987258cc9960de148561914" -dependencies = [ - "hashbrown 0.17.1", -] - [[package]] name = "duckdb" -version = "1.10505.0" -source = "git+https://github.com/windmill-labs/duckdb-rs?rev=7190adfcf58624e7e9a7a2a1000bac18fed64c19#7190adfcf58624e7e9a7a2a1000bac18fed64c19" +version = "1.10506.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3472b9152d879c5534353b20854e43525b2c78b2dbba156711e9939d30278b4" dependencies = [ "arrow", "cast", @@ -547,12 +539,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "funty" version = "2.0.0" @@ -644,7 +630,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -652,9 +638,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash 0.2.0", -] [[package]] name = "hashlink" @@ -813,11 +796,11 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libduckdb-sys" -version = "1.10505.0" -source = "git+https://github.com/windmill-labs/duckdb-rs?rev=7190adfcf58624e7e9a7a2a1000bac18fed64c19#7190adfcf58624e7e9a7a2a1000bac18fed64c19" +version = "1.10506.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aac3459f2f7f0221d5be9114fa77ff5877040c8de456d3b986a680579fe202b" dependencies = [ "cc", - "diffy", "flate2", "pkg-config", "serde", diff --git a/backend/windmill-duckdb-ffi-internal/Cargo.toml b/backend/windmill-duckdb-ffi-internal/Cargo.toml index 73e132a2d7..ba78e3522d 100644 --- a/backend/windmill-duckdb-ffi-internal/Cargo.toml +++ b/backend/windmill-duckdb-ffi-internal/Cargo.toml @@ -11,10 +11,9 @@ chrono = "0.4.41" # identical DECIMAL. Building one by hand would mean reimplementing that # f64 → (mantissa, scale) inference. # -# Not crates.io: a fork of duckdb-rs 1.10505.0 carrying two patches to the bundled engine — -# see docs/duckdb-isolation.md for why, and for the exit path back to the crates.io crate. -# Pinned by rev, not branch, so `cargo update` cannot move the engine under us. -duckdb = { git = "https://github.com/windmill-labs/duckdb-rs", rev = "7190adfcf58624e7e9a7a2a1000bac18fed64c19", features = [ +# Pinned exactly, so `cargo update` cannot move the engine (and the prebuilt extensions it +# must match) under us. +duckdb = { version = "=1.10506.0", features = [ "bundled", "rust_decimal", ] } @@ -25,5 +24,6 @@ serde = { version = "1.0", features = ["derive"] } # to avoid re-parsing every row. serde_json = { version = "^1.0.151", features = ["preserve_order", "raw_value"] } -[lib] -crate-type = ["cdylib"] +[[bin]] +name = "windmill_duckdb_runner" +path = "src/bin/windmill_duckdb_runner.rs" diff --git a/backend/windmill-duckdb-ffi-internal/README_DEV.md b/backend/windmill-duckdb-ffi-internal/README_DEV.md index 0c34ebc069..f5c5d61083 100644 --- a/backend/windmill-duckdb-ffi-internal/README_DEV.md +++ b/backend/windmill-duckdb-ffi-internal/README_DEV.md @@ -1,8 +1,8 @@ -This crate is compiled separately because it causes nasty issues when compiled with the deno_core feature flag enabled (lib c++ interactions). +This crate is compiled separately from the backend workspace because the bundled DuckDB's libc++ +clashes with `deno_core`'s when both live in one process. -The main issue was : -Errors in DuckDB always worked correctly, except when attached to a Ducklake and when the deno_core feature flag was set. -For example: +The symptom was opaque errors. DuckDB errors worked, except when attached to a DuckLake with the +`deno_core` feature on: ```sql ATTACH 'ducklake' AS dl; USE dl; @@ -10,6 +10,9 @@ CREATE TABLE IF NOT EXISTS t (x string not null); INSERT INTO t VALUES (NULL); ``` -causes `Constraint Error: NOT NULL constraint failed: t.x` normally, but here we see `Unknown exception in ExecutorTask::Execute`. This opaque errors comes directly from the C++ DuckDB library : https://github.com/duckdb/duckdb/blob/f99fed1e0b16a842573f9dad529f6c170a004f6e/src/parallel/executor_task.cpp#L58 +gave `Unknown exception in ExecutorTask::Execute` instead of `Constraint Error: NOT NULL +constraint failed: t.x`. The opaque error comes from the C++ DuckDB library: +https://github.com/duckdb/duckdb/blob/f99fed1e0b16a842573f9dad529f6c170a004f6e/src/parallel/executor_task.cpp#L58 -To solve this, we compile duckdb separately from the main backend crate and call it with FFI. It has to be loaded dynamically, if it is loaded statically it will still share lib c++ with deno_core and cause issues. +The worker never links this crate: it runs `windmill_duckdb_runner` (`src/bin`) as a separate +process for every job. See `docs/duckdb-execution.md`. diff --git a/backend/windmill-duckdb-ffi-internal/build_dev.sh b/backend/windmill-duckdb-ffi-internal/build_dev.sh index dab70398a8..c334baa6ae 100755 --- a/backend/windmill-duckdb-ffi-internal/build_dev.sh +++ b/backend/windmill-duckdb-ffi-internal/build_dev.sh @@ -31,22 +31,21 @@ else echo "duckdb-ffi: shared cache $CARGO_TARGET_DIR" # The cache key covers only the dependency inputs (Cargo.lock + build.rs), so # the expensive bundled-DuckDB build is shared even when this crate's own - # source differs between checkouts. But the shared dir's uplifted cdylib is a + # source differs between checkouts. But the shared dir's runner binary is a # single fixed-name artifact written by whichever worktree built last. Touch - # our sources so cargo re-links THIS checkout's cdylib and re-uplifts it + # our sources so cargo re-links THIS checkout's runner and re-uplifts it # before we copy — the copied artifact then always matches this worktree, # never a sibling's. The bundled dependency is a separate crate and stays # cached, so this only costs a ~1s relink. find src -type f -exec touch {} + fi -CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p windmill_duckdb_ffi_internal +cargo build --release -p windmill_duckdb_ffi_internal mkdir -p ../target/debug/ -# Install through a rename rather than writing over the destination: a running worker -# has the cdylib mapped, and rewriting those pages under it kills the process with -# SIGBUS. A rename swaps the directory entry and leaves the old inode alone. -for artifact in "$CARGO_TARGET_DIR/release/"libwindmill_duckdb_ffi_internal.*; do - dest="../target/debug/$(basename "$artifact")" - cp "$artifact" "$dest.tmp" - mv -f "$dest.tmp" "$dest" -done +# The worker looks for the runner next to its own binary. Install through a rename rather +# than writing over the destination: a runner may be executing, and rewriting its pages under +# it kills the process with SIGBUS. A rename swaps the directory entry and leaves the old +# inode alone. +dest="../target/debug/windmill_duckdb_runner" +cp "$CARGO_TARGET_DIR/release/windmill_duckdb_runner" "$dest.tmp" +mv -f "$dest.tmp" "$dest" diff --git a/backend/windmill-duckdb-ffi-internal/src/bin/windmill_duckdb_runner.rs b/backend/windmill-duckdb-ffi-internal/src/bin/windmill_duckdb_runner.rs new file mode 100644 index 0000000000..38052a9537 --- /dev/null +++ b/backend/windmill-duckdb-ffi-internal/src/bin/windmill_duckdb_runner.rs @@ -0,0 +1,122 @@ +//! Runs one DuckDB job, so the engine lives in a process of its own under the worker's job +//! isolation rather than in the worker. +//! +//! The worker writes one JSON [`Request`] to stdin and reads the answer from +//! `Request::result_path`: a first line holding the column order as JSON (empty when there is +//! none), then the collected results, or `ERROR `. +//! +//! stdout/stderr are not protocol: the worker keeps only their tail, and reports it when the +//! runner dies without writing a result. + +use std::io::{Read, Write}; + +use serde::Deserialize; +use windmill_duckdb_ffi_internal::{prepare, run, Arg, Connection}; + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum Mode { + Run, + Prepare, + /// `INSTALL` only, into the extension cache the jail later mounts read-only. Never loads + /// anything, so it runs no extension code. + Install, +} + +#[derive(Deserialize)] +struct Request { + mode: Mode, + blocks: Vec, + #[serde(default)] + job_args: Vec, + #[serde(default)] + token: String, + #[serde(default)] + base_internal_url: String, + #[serde(default)] + w_id: String, + #[serde(default)] + memory_limit: String, + #[serde(default)] + temp_directory: String, + #[serde(default)] + max_result_size: usize, + #[serde(default)] + collect_last_only: bool, + #[serde(default)] + collect_first_row_only: bool, + result_path: String, +} + +fn non_empty(s: &str) -> Option { + (!s.is_empty()).then(|| s.to_string()) +} + +fn install(blocks: &[String]) -> Result { + let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?; + for b in blocks { + conn.execute_batch(b).map_err(|e| e.to_string())?; + } + Ok("[]".to_string()) +} + +/// The result string and the column order as JSON. +fn execute(req: Request) -> (String, Option) { + let conn = Connection { + token: &req.token, + base_internal_url: &req.base_internal_url, + w_id: &req.w_id, + memory_limit: non_empty(&req.memory_limit), + temp_directory: non_empty(&req.temp_directory), + }; + let outcome = match req.mode { + Mode::Install => install(&req.blocks).map(|r| (r, None)), + Mode::Prepare => prepare(&req.blocks, conn).map(|r| (r, None)), + Mode::Run => run( + &req.blocks, + req.job_args, + conn, + req.max_result_size, + req.collect_last_only, + req.collect_first_row_only, + ) + .map(|(r, order)| { + let order = order.map(|o| serde_json::to_string(&o).unwrap_or_else(|_| "[]".into())); + (r, order) + }), + }; + outcome.unwrap_or_else(|e| { + let msg = serde_json::to_string(&e).unwrap_or_else(|_| "\"Unknown DuckDB error\"".into()); + (format!("ERROR {msg}"), None) + }) +} + +fn main() { + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + eprintln!("windmill_duckdb_runner: could not read the request: {e}"); + std::process::exit(2); + } + let req: Request = match serde_json::from_str(&input) { + Ok(r) => r, + Err(e) => { + eprintln!("windmill_duckdb_runner: invalid request: {e}"); + std::process::exit(2); + } + }; + drop(input); + + let result_path = req.result_path.clone(); + let (result, column_order) = execute(req); + let write = || -> std::io::Result<()> { + let mut f = std::io::BufWriter::new(std::fs::File::create(&result_path)?); + f.write_all(column_order.as_deref().unwrap_or("").as_bytes())?; + f.write_all(b"\n")?; + f.write_all(result.as_bytes())?; + f.flush() + }; + if let Err(e) = write() { + eprintln!("windmill_duckdb_runner: could not write the result to {result_path}: {e}"); + std::process::exit(2); + } +} diff --git a/backend/windmill-duckdb-ffi-internal/src/lib.rs b/backend/windmill-duckdb-ffi-internal/src/lib.rs index 69820b285f..0de7d3bd79 100644 --- a/backend/windmill-duckdb-ffi-internal/src/lib.rs +++ b/backend/windmill-duckdb-ffi-internal/src/lib.rs @@ -1,9 +1,9 @@ -use std::{ - collections::HashMap, - ffi::{c_char, c_uint, CStr, CString}, - ptr::null_mut, - sync::LazyLock, -}; +//! DuckDB execution for windmill jobs. The worker never loads this crate: it runs +//! `windmill_duckdb_runner` (src/bin), which calls [`run`] / [`prepare`] in a process of its +//! own, under the worker's job isolation. Kept out of the backend workspace because the +//! bundled engine's libc++ clashes with `deno_core`'s in one process. + +use std::{collections::HashMap, sync::LazyLock}; use duckdb::{core::LogicalTypeId, params_from_iter, types::TimeUnit, Row}; use regex::Regex; @@ -11,22 +11,6 @@ use rust_decimal::{prelude::FromPrimitive, Decimal}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; -// Worker passes "" for "no override" — saves an extra C string nullability dance. -// Returns an owned String so the value outlives the raw pointer's lifetime. -fn ptr_to_opt_str(ptr: *const c_char) -> Result, String> { - if ptr.is_null() { - return Ok(None); - } - let s = unsafe { CStr::from_ptr(ptr) } - .to_str() - .map_err(|e| format!("Invalid string in duckdb ffi: {}", e))?; - Ok(if s.is_empty() { - None - } else { - Some(s.to_owned()) - }) -} - #[derive(Deserialize, Clone, Debug, PartialEq, Default)] pub struct Arg { pub name: String, @@ -34,95 +18,69 @@ pub struct Arg { pub json_value: serde_json::Value, } -// Freeing from the caller side crashes the runtime with jemalloc enabled (EXIT CODE 11 SEGFAULT) -#[unsafe(no_mangle)] -pub extern "C" fn free_cstr(string: *mut c_char) -> () { - if string.is_null() { - return; - } - unsafe { - let _ = CString::from_raw(string); - } +/// Everything a connection is set up with before the script's first block runs. +pub struct Connection<'a> { + /// The job token: the S3/GCS secrets against the workspace's S3 proxy are derived from it. + pub token: &'a str, + pub base_internal_url: &'a str, + pub w_id: &'a str, + pub memory_limit: Option, + /// Where DuckDB spills; the job dir, so the spill is cleaned up with the job. + pub temp_directory: Option, } -#[unsafe(no_mangle)] -pub extern "C" fn get_version() -> c_uint { - // Increment when making breaking changes to the FFI interface. - // The windmill worker will check that the version matches or else refuse to call - // the FFI functions to avoid undefined behavior. - return 3; -} - -#[unsafe(no_mangle)] -pub extern "C" fn run_duckdb_ffi( - query_block_list: *const *const c_char, - query_block_list_count: usize, - job_args: *const c_char, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, - memory_limit: *const c_char, - temp_directory: *const c_char, - // 0 means unbounded; the worker sends its own budget-derived cap. +/// Runs the blocks in order on one in-memory connection and returns every collected result, +/// serialized, plus the last statement's column order. `max_result_size` 0 is unbounded. +pub fn run( + blocks: &[String], + job_args: Vec, + conn: Connection<'_>, max_result_size: usize, - column_order_ptr: *mut *mut c_char, collect_last_only: bool, collect_first_row_only: bool, -) -> *mut c_char { - let resource_limits = match (ptr_to_opt_str(memory_limit), ptr_to_opt_str(temp_directory)) { - (Ok(m), Ok(t)) => Ok(ResourceLimits::from_ffi(m, t)), - (Err(e), _) | (_, Err(e)) => Err(e), - }; - let (r, column_order) = match convert_args( - query_block_list, - query_block_list_count, - job_args, - token, - base_internal_url, - w_id, +) -> Result<(String, Option>), String> { + let limits = ResourceLimits::new(conn.memory_limit, conn.temp_directory); + run_duckdb_internal( + blocks.iter().map(String::as_str), + blocks.len(), + job_args_to_values(job_args), + conn.token, + conn.base_internal_url, + conn.w_id, + limits, + max_result_size, + collect_last_only, + collect_first_row_only, ) - .and_then(|args| resource_limits.map(|r| (args, r))) - .and_then( - |((query_block_list, job_args, token, base_internal_url, w_id), limits)| { - run_duckdb_internal( - query_block_list, - query_block_list_count, - job_args, - token, - base_internal_url, - w_id, - limits, - max_result_size, - collect_last_only, - collect_first_row_only, - ) - }, - ) { - Ok(result) => result, - Err(err) => { - let err = serde_json::to_string(&err) - .unwrap_or_else(|_| "Unknown error in duckdb ffi lib".to_string()); - (format!("ERROR {}", err), None) - } - }; +} - unsafe { - if let Some(column_order) = column_order { - let column_order = - serde_json::to_string(&column_order).unwrap_or_else(|_| "[]".to_string()); - let c_column_order = - CString::new(column_order).unwrap_or_else(|_| CString::new("[]").unwrap()); - *column_order_ptr = c_column_order.into_raw(); - } else { - *column_order_ptr = null_mut(); - } - } - // CString::into_raw because it needs to outlive this function call. - // The caller is responsible for freeing the memory through CString::from_raw. - CString::new(r).map(|s| s.into_raw()).unwrap_or_else(|e| { - println!("Failed to allocate error string in duckdb ffi lib: {:?}", e); - null_mut() - }) +/// Describes each non-setup block's result columns without running it. +pub fn prepare(blocks: &[String], conn: Connection<'_>) -> Result { + let limits = ResourceLimits::new(conn.memory_limit, conn.temp_directory); + prepare_duckdb_internal( + blocks.iter().map(String::as_str).collect(), + conn.token, + conn.base_internal_url, + conn.w_id, + limits, + ) +} + +fn job_args_to_values(job_args: Vec) -> HashMap { + job_args + .into_iter() + .map(|arg| { + let duckdb_value = json_value_to_duckdb_value(&arg.json_value, &arg.arg_type) + .unwrap_or_else(|e| { + println!( + "Error converting job_arg {} to duckdb value: {}", + arg.name, e + ); + duckdb::types::Value::Null + }); + (arg.name, duckdb_value) + }) + .collect() } #[derive(Serialize, Debug)] @@ -187,47 +145,6 @@ fn replace_params_with_null(query: &str) -> String { PARAM_RE.replace_all(query, "NULL").to_string() } -#[unsafe(no_mangle)] -pub extern "C" fn prepare_duckdb_ffi( - query_block_list: *const *const c_char, - query_block_list_count: usize, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, - memory_limit: *const c_char, - temp_directory: *const c_char, -) -> *mut c_char { - let resource_limits = match (ptr_to_opt_str(memory_limit), ptr_to_opt_str(temp_directory)) { - (Ok(m), Ok(t)) => Ok(ResourceLimits::from_ffi(m, t)), - (Err(e), _) | (_, Err(e)) => Err(e), - }; - let r = match convert_prepare_args( - query_block_list, - query_block_list_count, - token, - base_internal_url, - w_id, - ) - .and_then(|args| resource_limits.map(|r| (args, r))) - .and_then( - |((query_block_list, token, base_internal_url, w_id), limits)| { - prepare_duckdb_internal(query_block_list, token, base_internal_url, w_id, limits) - }, - ) { - Ok(result) => result, - Err(err) => { - let err = serde_json::to_string(&err) - .unwrap_or_else(|_| "Unknown error in duckdb ffi lib".to_string()); - format!("ERROR {}", err) - } - }; - - CString::new(r).map(|s| s.into_raw()).unwrap_or_else(|e| { - println!("Failed to allocate error string in duckdb ffi lib: {:?}", e); - null_mut() - }) -} - #[derive(Clone, Default)] struct ResourceLimits { memory_limit: Option, @@ -236,11 +153,10 @@ struct ResourceLimits { } impl ResourceLimits { - // The spill cap comes from the environment rather than from an FFI parameter: this - // cdylib is versioned separately from the worker and agent workers ship it out of band, - // so a signature change costs an ABI bump and a coordinated redeploy for what is a - // config value. Unset keeps DuckDB's default of 90% of the temp volume's free space. - fn from_ffi(memory_limit: Option, temp_directory: Option) -> Self { + // The spill cap is the worker's DUCKDB_MAX_TEMP_DIRECTORY_SIZE, passed through to the + // runner's environment. Unset keeps DuckDB's default of 90% of the temp volume's free + // space. A script can raise it, as it can write the job dir any other way. + fn new(memory_limit: Option, temp_directory: Option) -> Self { let max_temp_directory_size = std::env::var("DUCKDB_MAX_TEMP_DIRECTORY_SIZE") .ok() .map(|v| v.trim().to_string()) @@ -253,10 +169,9 @@ fn sql_single_quote(s: &str) -> String { s.replace('\'', "''") } -// Bounds memory so DuckDB spills before blowing the cgroup cap and getting the worker -// SIGKILLed, and bounds the spill so one query cannot fill its disk. Spill goes to the job -// dir when set, so it is cleaned up with the job; either way the directory and its cap are -// then locked for the rest of the connection. +// Bounds memory so DuckDB spills before blowing the cgroup cap and getting the job +// SIGKILLed, and sets a default bound on the spill. Spill goes to the job dir when set, so it +// is cleaned up with the job. fn configure_duckdb_resource_limits( conn: &duckdb::Connection, limits: &ResourceLimits, @@ -301,11 +216,7 @@ fn configure_duckdb_resource_limits( ) })?; } - // Freeze both before any script statement can touch them. A remote temp directory makes - // `~TemporaryDirectoryHandle` throw out of a `noexcept` destructor — `std::terminate`, - // taking this in-process worker and every job on it down, uncatchable from Rust — and a - // cap a script can raise bounds nothing. - run("SET lock_temp_directory=true;\n") + Ok(()) } fn setup_duckdb_connection( @@ -353,39 +264,6 @@ fn setup_duckdb_connection( .map_err(|e| format!("Error setting up S3 secret: {}", e.to_string())) } -fn convert_prepare_args<'a>( - query_block_list: *const *const c_char, - query_block_list_count: usize, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, -) -> Result<(Vec<&'a str>, &'a str, &'a str, &'a str), String> { - let query_block_list = unsafe { - std::slice::from_raw_parts(query_block_list, query_block_list_count) - .iter() - .map(|q| { - CStr::from_ptr(*q).to_str().unwrap_or_else(|e| { - println!( - "Invalid query_block string pointer in duckdb ffi: {}", - e.to_string() - ); - "Invalid query_block string pointer in duckdb ffi" - }) - }) - .collect::>() - }; - let token = unsafe { CStr::from_ptr(token) } - .to_str() - .map_err(|e| format!("Invalid token string: {}", e.to_string()))?; - let base_internal_url = unsafe { CStr::from_ptr(base_internal_url) } - .to_str() - .map_err(|e| format!("Invalid base_internal_url string: {}", e.to_string()))?; - let w_id = unsafe { CStr::from_ptr(w_id) } - .to_str() - .map_err(|e| format!("Invalid w_id string: {}", e.to_string()))?; - Ok((query_block_list, token, base_internal_url, w_id)) -} - fn prepare_duckdb_internal( query_block_list: Vec<&str>, token: &str, @@ -453,67 +331,6 @@ fn prepare_duckdb_internal( serde_json::to_string(&results).map_err(|e| e.to_string()) } -fn convert_args<'a>( - query_block_list: *const *const c_char, - query_block_list_count: usize, - job_args: *const c_char, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, -) -> Result< - ( - impl Iterator, - HashMap, - &'a str, - &'a str, - &'a str, - ), - String, -> { - let query_block_list = unsafe { - std::slice::from_raw_parts(query_block_list, query_block_list_count) - .iter() - .map(|q| { - CStr::from_ptr(*q).to_str().unwrap_or_else(|e| { - println!( - "Invalid query_block string pointer in duckdb ffi: {}", - e.to_string() - ); - "Invalid query_block string pointer in duckdb ffi" - }) - }) - }; - let job_args_str = unsafe { CStr::from_ptr(job_args) } - .to_str() - .map_err(|e| format!("Invalid job_args string: {}", e.to_string()))?; - let job_args: Vec = serde_json::from_str(job_args_str) - .map_err(|e| format!("Invalid job_args JSON: {}", e.to_string()))?; - let job_args: HashMap = job_args - .into_iter() - .map(|arg| { - let duckdb_value = json_value_to_duckdb_value(&arg.json_value, &arg.arg_type) - .unwrap_or_else(|e| { - println!( - "Error converting job_arg {} to duckdb value: {}", - arg.name, e - ); - duckdb::types::Value::Null - }); - (arg.name, duckdb_value) - }) - .collect(); - let token = unsafe { CStr::from_ptr(token) } - .to_str() - .map_err(|e| format!("Invalid token string: {}", e.to_string()))?; - let base_internal_url = unsafe { CStr::from_ptr(base_internal_url) } - .to_str() - .map_err(|e| format!("Invalid base_internal_url string: {}", e.to_string()))?; - let w_id = unsafe { CStr::from_ptr(w_id) } - .to_str() - .map_err(|e| format!("Invalid w_id string: {}", e.to_string()))?; - Ok((query_block_list, job_args, token, base_internal_url, w_id)) -} - // TODO : Better error return to leverage different error kinds on the worker side fn run_duckdb_internal<'a>( query_block_list: impl Iterator, @@ -1230,8 +1047,8 @@ mod temporal_json_tests { // Numbers too wide for a JSON number are rendered as strings. DECIMAL runs to // 38 digits and UHUGEINT to 2^128-1; rendering either through a type that - // cannot hold it aborts the whole worker rather than erroring, because the - // panic escapes an `extern "C"` frame and those cannot unwind. + // cannot hold it panics, which kills the runner and fails the job with no + // engine message to show for it. #[test] fn wide_numbers_render_as_strings_without_losing_precision() { let conn = duckdb::Connection::open_in_memory().unwrap(); @@ -1677,214 +1494,3 @@ fn string_to_duckdb_time(s: &str) -> Result { time.num_seconds_from_midnight() as i64, )) } - -// The bundled engine is not stock (docs/duckdb-isolation.md). These guard the ways an engine -// bump can drop the patches without anything else noticing. -#[cfg(test)] -mod patched_engine_tests { - use super::*; - - /// Too large to be answered within the `memory_limit` `spill_attempt` sets, so the engine - /// has to reach the temp directory to finish it. - const SPILLING_QUERY: &str = "CREATE TABLE t AS SELECT * FROM range(1000000)"; - - /// Every connection is locked at setup, so no script statement can move the temp - /// directory onto a remote filesystem — where the spill fails and the - /// `~TemporaryDirectoryHandle` cleaning up throws out of a `noexcept` destructor, - /// aborting the worker and every job colocated on it. - #[test] - fn configured_connections_cannot_have_their_temp_directory_moved() { - let temp_dir = std::env::temp_dir().join(format!( - "windmill_duckdb_locked_{}_{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - - let conn = duckdb::Connection::open_in_memory().unwrap(); - configure_duckdb_resource_limits( - &conn, - &ResourceLimits { - memory_limit: Some("2MB".to_string()), - temp_directory: Some(temp_dir.display().to_string()), - max_temp_directory_size: None, - }, - ) - .unwrap(); - - for stmt in [ - "SET temp_directory='s3://bucket/tmp'", - "SET temp_directory TO 's3://bucket/tmp'", - "PRAGMA temp_directory='s3://bucket/tmp'", - "RESET temp_directory", - ] { - let err = conn - .execute_batch(stmt) - .expect_err(&format!("engine accepted `{stmt}` on a locked connection")); - assert!( - err.to_string().contains("temp directory has been locked"), - "unexpected refusal for `{stmt}`: {err}" - ); - } - - // The lock must not cost the spilling it exists to protect. - let spilled = conn.execute_batch(SPILLING_QUERY); - std::fs::remove_dir_all(&temp_dir).ok(); - spilled.expect("a locked temp directory must still be usable for spilling"); - } - - /// `DUCKDB_MAX_TEMP_DIRECTORY_SIZE` is the only thing standing between one query and the - /// worker's whole disk, so pin that a configured connection really does stop at it, names - /// the setting when it does, and leaves nothing behind for the next job to trip over. - #[test] - fn spilling_past_the_configured_cap_fails_the_query_and_cleans_up() { - let temp_dir = std::env::temp_dir().join(format!( - "windmill_duckdb_capped_{}_{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - - let spill_under_cap = |cap: &str| { - let conn = duckdb::Connection::open_in_memory().unwrap(); - configure_duckdb_resource_limits( - &conn, - &ResourceLimits { - memory_limit: Some("2MB".to_string()), - temp_directory: Some(temp_dir.display().to_string()), - max_temp_directory_size: Some(cap.to_string()), - }, - ) - .unwrap(); - conn.execute_batch(SPILLING_QUERY) - .map_err(|e| e.to_string()) - }; - - let err = spill_under_cap("1MB").expect_err("the cap did not stop the spill"); - // Verbatim because the worker matches on it (`SPILL_CAP_ENGINE_MARKER` in - // `duckdb_executor.rs`) to correct the engine's advice to raise the cap, which the - // lock refuses. An engine bump that rewords this has to update that too. - assert!( - err.contains("This limit was set by the 'max_temp_directory_size' setting"), - "the refusal must name the setting an operator can act on, got: {err}" - ); - let leftover: Vec<_> = std::fs::read_dir(&temp_dir) - .unwrap() - .filter_map(|e| e.ok().map(|e| e.path())) - .collect(); - assert!( - leftover.is_empty(), - "a query killed by the cap must not leave its spill behind: {leftover:?}" - ); - - // DuckDB compresses temp files, so the failure above is only evidence of the cap if - // the same query gets through once the cap is out of the way. - let generous = spill_under_cap("1GB"); - std::fs::remove_dir_all(&temp_dir).ok(); - generous.expect("the query must still spill and finish under a cap that fits it"); - } - - fn spill_attempt(temp_dir: &std::path::Path, lock: bool) -> Result<(), String> { - let conn = duckdb::Connection::open_in_memory().unwrap(); - conn.execute_batch(&format!( - "SET threads=2; SET memory_limit='2MB'; SET temp_directory='{}';", - temp_dir.display() - )) - .unwrap(); - if lock { - conn.execute_batch("SET lock_temp_directory=true;") - .map_err(|e| format!("lock_temp_directory rejected: {e}"))?; - } - conn.execute_batch("SET disabled_filesystems='LocalFileSystem';") - .unwrap(); - conn.execute_batch(SPILLING_QUERY) - .map_err(|e| e.to_string()) - } - - #[test] - fn locking_the_temp_directory_keeps_spilling_available() { - let temp_dir = std::env::temp_dir().join(format!( - "windmill_duckdb_spill_{}_{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Without the lock the fence reaches the buffer manager too, which is both the - // stock engine's behaviour and the proof that this query really does spill — - // otherwise the assertion below would pass on an unpatched engine. - let unlocked = spill_attempt(&temp_dir, false).expect_err("query did not need to spill"); - assert!( - unlocked.contains("LocalFileSystem has been disabled"), - "expected the fence to stop the spill, got: {unlocked}" - ); - - let locked = spill_attempt(&temp_dir, true); - std::fs::remove_dir_all(&temp_dir).ok(); - locked.expect("a locked temp directory must stay usable behind disabled_filesystems"); - } - - /// The exemption above is only sound while neither the spill's destination nor its size - /// can be changed: repointing the directory is a write primitive through the very fence - /// it is exempt from, and raising the cap leaves the disk unbounded. A rebase can drop - /// either half while leaving the spill test green, so pin both. - #[test] - fn locking_the_temp_directory_makes_it_immutable() { - let conn = duckdb::Connection::open_in_memory().unwrap(); - conn.execute_batch("SET temp_directory='/tmp/windmill_duckdb_lock_probe';") - .unwrap(); - conn.execute_batch("SET lock_temp_directory=true;") - .expect("the patched engine must accept lock_temp_directory"); - - for stmt in [ - "SET temp_directory='/tmp/windmill_duckdb_elsewhere';", - "RESET temp_directory;", - "SET max_temp_directory_size='100GB';", - "RESET max_temp_directory_size;", - "SET lock_temp_directory=false;", - "RESET lock_temp_directory;", - ] { - let err = conn.execute_batch(stmt).expect_err(&format!( - "`{stmt}` must be refused once the directory is locked" - )); - assert!( - err.to_string().contains("locked"), - "expected a lock refusal for `{stmt}`, got: {err}" - ); - } - } - - // The engine loads the *prebuilt* extensions from extensions.duckdb.org, which are - // compiled against the struct layouts of the release it claims to be. A patch that - // moves a member those extensions reach does not fail — the extension reads the - // neighbouring field and LOAD blocks forever — so this is a timeout, not an assertion. - #[test] - fn prebuilt_extensions_still_load() { - let conn = duckdb::Connection::open_in_memory().unwrap(); - // Fetching the extension needs egress, which loading it back — the part this guards — - // does not. Skipping keeps a developer offline from seeing a failure they can't act on, - // but never in CI, where a skip and a pass would be indistinguishable. - if let Err(e) = conn.execute_batch("INSTALL httpfs;") { - assert!( - std::env::var_os("CI").is_none(), - "could not install httpfs, so the extension-load guard did not run: {e}" - ); - eprintln!("skipping: INSTALL httpfs failed ({e})"); - return; - } - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - tx.send( - conn.execute_batch("LOAD httpfs;") - .map_err(|e| e.to_string()), - ) - }); - match rx.recv_timeout(std::time::Duration::from_secs(120)) { - Ok(r) => r.expect("could not load httpfs"), - Err(_) => panic!( - "LOAD httpfs did not return: the patched engine's struct layout no longer matches \ - the release the prebuilt extensions were built against" - ), - } - } -} diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 57b292f8f2..68728a9713 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -39,7 +39,7 @@ nu = ["dep:windmill-parser-nu"] java = ["dep:windmill-parser-java"] ruby = ["dep:windmill-parser-ruby"] rlang = ["dep:windmill-parser-r"] -duckdb = ["dep:libloading"] +duckdb = [] quickjs = ["windmill-jseval/quickjs", "windmill-queue/quickjs"] bedrock = ["windmill-ai/bedrock"] @@ -143,7 +143,6 @@ backon.workspace = true pep440_rs.workspace = true process-wrap.workspace = true async-once-cell.workspace = true -libloading = { workspace = true, optional = true } opentelemetry-proto = { workspace = true, optional = true } opentelemetry = { workspace = true, optional = true } diff --git a/backend/windmill-worker/nsjail/run.duckdb.config.proto b/backend/windmill-worker/nsjail/run.duckdb.config.proto new file mode 100644 index 0000000000..426f3d583d --- /dev/null +++ b/backend/windmill-worker/nsjail/run.duckdb.config.proto @@ -0,0 +1,145 @@ +name: "duckdb run" + +mode: ONCE +hostname: "duckdb" +log_level: ERROR +# The executor's own job timeout kills the jail; nsjail adds none of its own. +time_limit: 0 + +# DuckDB bounds its memory through memory_limit, and the worker bounds its disk use (see the +# work dir mount). rlimit_as would break it: jemalloc reserves address space well past what it +# ever touches. +disable_rl: true + +cwd: "/tmp" + +clone_newnet: false +clone_newuser: {CLONE_NEWUSER} + +skip_setsid: true +keep_caps: false +keep_env: true +mount_proc: true + +mount { + src: "/lib" + dst: "/lib" + is_bind: true +} + +mount { + src: "/lib64" + dst: "/lib64" + is_bind: true + mandatory: false +} + +mount { + src: "/usr" + dst: "/usr" + is_bind: true +} + +mount { + src: "/proc/self/fd" + dst: "/dev/fd" + is_symlink: true + mandatory: false +} + +mount { + src: "/dev/null" + dst: "/dev/null" + is_bind: true + rw: true +} + +mount { + src: "/dev/random" + dst: "/dev/random" + is_bind: true +} + +mount { + src: "/dev/urandom" + dst: "/dev/urandom" + is_bind: true +} + +# DuckDB sizes its thread pool from the cgroup's CPU quota. +mount { + src: "/sys/fs/cgroup" + dst: "/sys/fs/cgroup" + is_bind: true + mandatory: false +} + +mount { + src: "/etc" + dst: "/etc" + is_bind: true +} + +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + +{TMP_MOUNT_BLOCK} + +# At its host path, so every path the worker wrote into the blocks (Postgres root +# certificates) resolves unchanged. Read-only: the one writable place on the host is the +# runner's work dir below, which the worker caps at DUCKDB_MAX_TEMP_DIRECTORY_SIZE. +mount { + src: "{JOB_DIR}" + dst: "{JOB_DIR}" + is_bind: true +} + +# The spill directory and the result file. +mount { + src: "{JOB_DIR}/duckdb" + dst: "{JOB_DIR}/duckdb" + is_bind: true + rw: true +} + +mount { + src: "{RUNNER_PATH}" + dst: "/tmp/windmill_duckdb_runner" + is_bind: true +} + +# Read-only: the worker installs into it before the jail starts, so a job can load what it +# named but can neither add to nor replace what the next job loads. +mount { + src: "{EXTENSION_DIR}" + dst: "/tmp/.duckdb/extensions" + is_bind: true + mandatory: false +} + +{BIGQUERY_CREDENTIALS_MOUNT} + +envar: "HOME=/tmp" + +#{DEV} diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index 513d30d181..05c35fce8d 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -1,10 +1,6 @@ -use std::cell::RefCell; use std::env; -use std::ffi::{c_char, c_uint, CStr, CString}; -use std::ptr::NonNull; use std::sync::{Arc, Mutex}; -use libloading::{Library, Symbol}; use serde::Serialize; use serde_json::value::RawValue; use serde_json::{json, Value}; @@ -14,7 +10,8 @@ use windmill_common::utils::sanitize_string_from_password; use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy}; use windmill_common::workspaces::{ get_datatable_resource_from_db, get_ducklake_from_db_unchecked, - strip_fork_reserved_attach_args, DatatableAccess, DatatableConnectAs, DucklakeCatalogResourceType, + strip_fork_reserved_attach_args, DatatableAccess, DatatableConnectAs, + DucklakeCatalogResourceType, }; use windmill_common::PgDatabase; use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE; @@ -24,6 +21,7 @@ use windmill_types::s3::S3Object; use crate::agent_workers::{get_datatable_resource_from_agent_http, get_ducklake_from_agent_http}; use crate::common::{build_args_values, get_reserved_variables, OccupancyMetrics}; +use crate::duckdb_runner::{RequestMode, RunnerMode, RunnerRequest}; use crate::handle_child::run_future_with_polling_update_job_poller; #[cfg(feature = "mysql")] use crate::mysql_executor::MysqlDatabase; @@ -405,23 +403,19 @@ async fn compute_sync_alter_ddl( base_internal_url: String, w_id: String, job_dir: String, + runner: RunnerMode, ) -> Result { - let n = probe_blocks.len(); - let (result, _) = tokio::task::spawn_blocking(move || { - run_duckdb_ffi_safe( - probe_blocks.iter().map(String::as_str), - n, - job_args, - &token, - &base_internal_url, - &w_id, - &job_dir, - SqlResultCollectionStrategy::LastStatementAllRows, - ) - }) + let (result, _) = run_duckdb( + runner, + probe_blocks, + job_args, + token, + base_internal_url, + w_id, + job_dir, + SqlResultCollectionStrategy::LastStatementAllRows, + ) .await - .map_err(|e| Error::from(to_anyhow(e))) - .and_then(|r| r) .map_err(|e| { Error::ExecutionErr(format!( "on_schema_change=sync: the schema pre-pass probe failed ({e}); refusing to write" @@ -1328,6 +1322,7 @@ pub async fn do_duckdb( let token = client.token.clone(); let hidden_passwords = Arc::new(Mutex::new(Vec::::new())); + let runner = crate::duckdb_runner::runner_mode(); let result_f = async { let mut hidden_passwords = hidden_passwords.clone(); @@ -1522,10 +1517,6 @@ pub async fn do_duckdb( probe_blocks, ) .await?; - // The probe opens its own DuckDB connection, so it needs the same - // connection policy as the main query below. - #[cfg(feature = "private")] - let probe_blocks = crate::duckdb_isolation_ee::apply_duckdb_isolation(probe_blocks)?; let alter_ddl = compute_sync_alter_ddl( probe_blocks, job_args.clone(), @@ -1534,6 +1525,7 @@ pub async fn do_duckdb( client.base_internal_url.clone(), job.workspace_id.clone(), job_dir.to_string(), + runner, ) .await?; query = query.replace( @@ -1610,46 +1602,34 @@ pub async fn do_duckdb( ) .await?; - #[cfg(feature = "private")] - let query_block_list = - crate::duckdb_isolation_ee::apply_duckdb_isolation(query_block_list)?; let base_internal_url = client.base_internal_url.clone(); let w_id = job.workspace_id.clone(); let job_dir = job_dir.to_string(); if annotations.prepare { - let result = tokio::task::spawn_blocking(move || { - prepare_duckdb_ffi_safe( - query_block_list.iter().map(String::as_str), - &token, - &base_internal_url, - &w_id, - &job_dir, - ) - }) - .await - .map_err(|e| Error::from(to_anyhow(e))) - .and_then(|r| r)?; - - return Ok(result); + return prepare_duckdb( + runner, + query_block_list, + token.clone(), + base_internal_url, + w_id, + job_dir, + ) + .await; } - let result = tokio::task::spawn_blocking(move || { - run_duckdb_ffi_safe( - query_block_list.iter().map(String::as_str), - query_block_list.len(), - job_args, - &token, - &base_internal_url, - &w_id, - &job_dir, - collection_strategy, - ) - }) - .await - .map_err(|e| Error::from(to_anyhow(e))) - .and_then(|r| r); + let result = run_duckdb( + runner, + query_block_list, + job_args, + token.clone(), + base_internal_url, + w_id, + job_dir, + collection_strategy, + ) + .await; let (result, column_order) = match result { Ok(r) => r, @@ -1857,8 +1837,6 @@ pub async fn do_duckdb( match result { Ok(result) => Ok(result), Err(e) => { - #[cfg(feature = "private")] - let e = crate::duckdb_isolation_ee::explain_error(e); // Passwords might appear in the error message — and, for the // structured data-test failure, in sampled row data read from an // attached database — so every outgoing error is sanitized here. @@ -1903,111 +1881,6 @@ pub async fn do_duckdb( } } -thread_local! { - static DUCKDB_FFI_LIB_SINGLETON: RefCell<*const DuckDbFfiLib> = RefCell::new(std::ptr::null()); -} - -struct DuckDbFfiLib { - run_duckdb_ffi: Symbol< - 'static, - unsafe extern "C" fn( - query_block_list: *const *const c_char, - query_block_list_count: usize, - job_args: *const c_char, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, - memory_limit: *const c_char, - temp_directory: *const c_char, - max_result_size: usize, - column_order_ptr: *mut *mut c_char, - collect_last_only: bool, - collect_first_row_only: bool, - ) -> *mut c_char, - >, - prepare_duckdb_ffi: Option< - Symbol< - 'static, - unsafe extern "C" fn( - query_block_list: *const *const c_char, - query_block_list_count: usize, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, - memory_limit: *const c_char, - temp_directory: *const c_char, - ) -> *mut c_char, - >, - >, - free_cstr: Symbol<'static, unsafe extern "C" fn(string: *mut c_char) -> ()>, -} - -impl DuckDbFfiLib { - fn get_singleton() -> Result<&'static DuckDbFfiLib> { - DUCKDB_FFI_LIB_SINGLETON.with(|cell| unsafe { - let mut singleton = cell.borrow_mut(); - if singleton.is_null() { - let lib = DuckDbFfiLib::init()?; - let boxed_lib = Box::new(lib); - let lib_ptr = Box::leak(boxed_lib); - *singleton = lib_ptr as *const _; - Ok(NonNull::new_unchecked(*singleton as *mut DuckDbFfiLib).as_ref()) - } else { - Ok(&**singleton) - } - }) - } - fn init() -> Result { - let lib = unsafe { - Library::new(if cfg!(target_os = "macos") { - "libwindmill_duckdb_ffi_internal.dylib" - } else if cfg!(target_os = "windows") { - "windmill_duckdb_ffi_internal.dll" - } else { - "libwindmill_duckdb_ffi_internal.so" - }) - .map_err(|e| { - Error::InternalErr(format!( - "Could not init duckdb. Make sure you have the latest windmill_duckdb_ffi_lib.{} alongside your binary : https://github.com/windmill-labs/windmill/releases \n{}", - if cfg!(target_os = "macos") { "dylib" } - else if cfg!(target_os = "windows") { "dll" } - else { "so" }, - e.to_string() - )) - })? - }; - - let lib = Box::leak(Box::new(lib)); - - // Version mismatch should only be possible on Windows agent workers - // We check for it because FFI interface mismatch will cause undefined behavior / crashes - unsafe { - let expected_version: c_uint = 3; - let get_version: Symbol<'static, unsafe extern "C" fn() -> c_uint> = - lib.get(b"get_version") - .map_err(|e| return Error::ExecutionErr(format!("Could not find get_version in the duckdb ffi library. If you are not using docker, consider manually upgrading windmill_duckdb_ffi_lib. {}", e.to_string())))?; - let actual_version = get_version(); - if actual_version < expected_version { - return Err(Error::InternalErr( - format!("Incompatible duckdb ffi library version. Expected: {expected_version}, actual: {actual_version}. Please update to the latest windmill_duckdb_ffi_lib."), - )); - } else if actual_version > expected_version { - return Err(Error::InternalErr( - format!("Incompatible duckdb ffi library version. Expected: {expected_version}, actual: {actual_version}. Please upgrade your worker to the latest windmill version."), - )); - } - } - - let prepare_duckdb_ffi = unsafe { lib.get(b"prepare_duckdb_ffi").ok() }; - - Ok(DuckDbFfiLib { - run_duckdb_ffi: unsafe { lib.get(b"run_duckdb_ffi").map_err(to_anyhow)? }, - prepare_duckdb_ffi, - free_cstr: unsafe { lib.get(b"free_cstr").map_err(to_anyhow)? }, - }) - } -} - // 20% headroom for Rust runtime + DuckDB's untracked allocations. Mirrors // DuckDB's own default ratio, but applied to the worker's cgroup budget // instead of host RAM. @@ -2037,123 +1910,29 @@ fn cgroup_bytes_to_duckdb_memory_limit(bytes: i64) -> Option { Some(format!("{}MiB", mib.max(64))) } -// Read backend/windmill-duckdb-ffi-internal/README_DEV.md for details about why we use FFI -// The FFI returns errors as `ERROR ` (the message is -// serde_json::to_string'd). Decode the JSON string back so multi-line errors -// render with real newlines, not escaped `\n` inside wrapping quotes; fall back -// to the raw slice if it is somehow not a JSON string. +// The runner reports an engine error as `ERROR `. Decode the JSON string +// back so multi-line errors render with real newlines, not escaped `\n` inside wrapping quotes; +// fall back to the raw slice if it is somehow not a JSON string. fn decode_ffi_error(result_str: &str) -> String { let raw = result_str.strip_prefix("ERROR ").unwrap_or(result_str); - let msg = serde_json::from_str::(raw).unwrap_or_else(|_| raw.to_string()); - // Mirrors the filter the FFI applies to the same variable (`ResourceLimits::from_ffi`); - // they live in separate crates, so the rule is stated twice and must stay one rule. - let configured = env::var("DUCKDB_MAX_TEMP_DIRECTORY_SIZE").is_ok_and(|v| !v.trim().is_empty()); - match spill_cap_hint(&msg, configured) { - Some(hint) => format!("{msg}\n{hint}"), - None => msg, - } + serde_json::from_str::(raw).unwrap_or_else(|_| raw.to_string()) } -/// DuckDB's own wording when a query outgrows the spill cap. Asserted against real engine -/// output by `spilling_past_the_configured_cap_fails_the_query_and_cleans_up` in -/// `windmill-duckdb-ffi-internal`, so an engine bump that rewords it fails there rather than -/// silently dropping the hint. -const SPILL_CAP_ENGINE_MARKER: &str = "This limit was set by the 'max_temp_directory_size' setting"; - -/// Split out of [`decode_ffi_error`] so the branch is testable without mutating the process -/// environment. The engine emits the same text for a configured cap and for its own default, -/// so which limit was hit has to come from the worker's config, not from the message. -fn spill_cap_hint(msg: &str, configured: bool) -> Option<&'static str> { - if !msg.contains(SPILL_CAP_ENGINE_MARKER) { - return None; - } - Some(if configured { - SPILL_CAP_CONFIGURED - } else { - SPILL_CAP_DEFAULT - }) -} - -/// Both hints open by contradicting the engine, whose message ends by suggesting -/// `PRAGMA max_temp_directory_size='10GiB'`: every windmill connection refuses that, since the -/// cap is frozen along with the temp directory, so following it yields a second, -/// unrelated-looking permission error. True either way, because the lock is unconditional. -const SPILL_CAP_CONFIGURED: &str = "\nThe PRAGMA suggested above is refused: this connection's \ - temp directory and the cap on it are frozen, so a script cannot raise the limit. That limit \ - is the worker's DUCKDB_MAX_TEMP_DIRECTORY_SIZE. Spill less (fewer threads, a narrower scan, \ - aggregating before the join), or ask an operator to raise it."; - -/// Nothing configured a cap, so this is DuckDB's own default and the worker is genuinely low on -/// disk. Naming the env var here would send the reader after a knob nobody set. -const SPILL_CAP_DEFAULT: &str = "\nThe PRAGMA suggested above is refused: this connection's temp \ - directory and the cap on it are frozen, so a script cannot raise the limit. No cap is \ - configured on this worker, so that limit is DuckDB's default of 90% of the free space on \ - its temp volume: the worker is low on disk. Free space on it, or spill less (fewer threads, \ - a narrower scan, aggregating before the join)."; - -fn run_duckdb_ffi_safe<'a>( - query_block_list: impl Iterator, +/// Turns what the runner returned into the job result. +fn finish_run( + result_str: String, + column_order: Option, query_block_list_count: usize, - job_args: Vec, - token: &str, - base_internal_url: &str, - w_id: &str, - job_dir: &str, collection_strategy: SqlResultCollectionStrategy, ) -> Result<(Box, Option>)> { - let query_block_list = query_block_list - .map(|s| { - CString::new(s).map_err(|e| { - Error::ExecutionErr(format!("Failed CString conversion: {}", e.to_string())) - }) - }) - .collect::>>()?; - let query_block_list = query_block_list - .iter() - .map(|s| s.as_ptr()) - .collect::>(); - let job_args = serde_json::to_string(&job_args).map_err(to_anyhow)?; - - let job_args = CString::new(job_args).map_err(to_anyhow)?; - let token = CString::new(token).map_err(to_anyhow)?; - let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?; - let w_id = CString::new(w_id).map_err(to_anyhow)?; - let memory_limit = - CString::new(resolve_duckdb_memory_limit().unwrap_or_default()).map_err(to_anyhow)?; - let temp_directory = CString::new(job_dir).map_err(to_anyhow)?; - - let run_duckdb_ffi = &DuckDbFfiLib::get_singleton()?.run_duckdb_ffi; - let free_cstr = &DuckDbFfiLib::get_singleton()?.free_cstr; - let mut column_order: *mut c_char = std::ptr::null_mut(); - let result_str = unsafe { - let ptr = run_duckdb_ffi( - query_block_list.as_ptr(), - query_block_list_count, - job_args.as_ptr(), - token.as_ptr(), - base_internal_url.as_ptr(), - w_id.as_ptr(), - memory_limit.as_ptr(), - temp_directory.as_ptr(), - *MAX_SQL_RESULT_SIZE, - &mut column_order, - collection_strategy.collect_last_statement_only(query_block_list_count), - collection_strategy.collect_first_row_only(), - ); - let str = CStr::from_ptr(ptr).to_string_lossy().to_string(); - free_cstr(ptr); - str - }; - - let column_order = if column_order.is_null() - || !collection_strategy.collect_last_statement_only(query_block_list_count) - || collection_strategy.collect_scalar() - { - None - } else { - let str = unsafe { CStr::from_ptr(column_order).to_string_lossy().to_string() }; - unsafe { free_cstr(column_order) }; - Some(serde_json::from_str::>(&str)?) + let column_order = match column_order { + Some(str) + if collection_strategy.collect_last_statement_only(query_block_list_count) + && !collection_strategy.collect_scalar() => + { + Some(serde_json::from_str::>(&str)?) + } + _ => None, }; if result_str.starts_with("ERROR") { @@ -2171,55 +1950,7 @@ fn run_duckdb_ffi_safe<'a>( } } -fn prepare_duckdb_ffi_safe<'a>( - query_block_list: impl Iterator, - token: &str, - base_internal_url: &str, - w_id: &str, - job_dir: &str, -) -> Result> { - let query_block_list = query_block_list - .map(|s| { - CString::new(s).map_err(|e| { - Error::ExecutionErr(format!("Failed CString conversion: {}", e.to_string())) - }) - }) - .collect::>>()?; - let query_block_list = query_block_list - .iter() - .map(|s| s.as_ptr()) - .collect::>(); - - let token = CString::new(token).map_err(to_anyhow)?; - let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?; - let w_id = CString::new(w_id).map_err(to_anyhow)?; - let memory_limit = - CString::new(resolve_duckdb_memory_limit().unwrap_or_default()).map_err(to_anyhow)?; - let temp_directory = CString::new(job_dir).map_err(to_anyhow)?; - - let lib = DuckDbFfiLib::get_singleton()?; - let prepare_fn = lib.prepare_duckdb_ffi.as_ref().ok_or_else(|| { - Error::InternalErr( - "prepare_duckdb_ffi not available in duckdb ffi library. Please update to the latest windmill_duckdb_ffi_lib.".to_string(), - ) - })?; - let free_cstr = &lib.free_cstr; - - let result_str = unsafe { - let ptr = prepare_fn( - query_block_list.as_ptr(), - query_block_list.len(), - token.as_ptr(), - base_internal_url.as_ptr(), - w_id.as_ptr(), - memory_limit.as_ptr(), - temp_directory.as_ptr(), - ); - let str = CStr::from_ptr(ptr).to_string_lossy().to_string(); - free_cstr(ptr); - str - }; - +fn finish_prepare(result_str: String) -> Result> { if result_str.starts_with("ERROR") { Err(Error::ExecutionErr(decode_ffi_error(&result_str))) } else { @@ -2227,6 +1958,70 @@ fn prepare_duckdb_ffi_safe<'a>( } } +async fn run_duckdb( + runner: RunnerMode, + query_block_list: Vec, + job_args: Vec, + token: String, + base_internal_url: String, + w_id: String, + job_dir: String, + collection_strategy: SqlResultCollectionStrategy, +) -> Result<(Box, Option>)> { + let n = query_block_list.len(); + let job_args = serde_json::value::to_raw_value(&job_args).map_err(to_anyhow)?; + let memory_limit = resolve_duckdb_memory_limit().unwrap_or_default(); + let out = crate::duckdb_runner::run( + runner, + &RunnerRequest { + mode: RequestMode::Run, + blocks: &query_block_list, + job_args: Some(&job_args), + token: &token, + base_internal_url: &base_internal_url, + w_id: &w_id, + memory_limit: &memory_limit, + temp_directory: &job_dir, + max_result_size: *MAX_SQL_RESULT_SIZE, + collect_last_only: collection_strategy.collect_last_statement_only(n), + collect_first_row_only: collection_strategy.collect_first_row_only(), + }, + &job_dir, + ) + .await?; + finish_run(out.result, out.column_order, n, collection_strategy) +} + +async fn prepare_duckdb( + runner: RunnerMode, + query_block_list: Vec, + token: String, + base_internal_url: String, + w_id: String, + job_dir: String, +) -> Result> { + let memory_limit = resolve_duckdb_memory_limit().unwrap_or_default(); + let out = crate::duckdb_runner::run( + runner, + &RunnerRequest { + mode: RequestMode::Prepare, + blocks: &query_block_list, + job_args: None, + token: &token, + base_internal_url: &base_internal_url, + w_id: &w_id, + memory_limit: &memory_limit, + temp_directory: &job_dir, + max_result_size: 0, + collect_last_only: false, + collect_first_row_only: false, + }, + &job_dir, + ) + .await?; + finish_prepare(out.result) +} + struct ParsedAttachDbResource<'a> { resource_path: &'a str, name: &'a str, @@ -3057,33 +2852,6 @@ mod tests { assert_eq!(decode_ffi_error("ERROR not json"), "not json"); } - /// The engine says the same thing whether the cap came from the worker or is its own - /// default, so the attribution has to come from the config — naming the env var on a - /// worker that never set it would send the reader after the wrong problem. - #[test] - fn spill_cap_hint_attributes_the_limit_to_whoever_set_it() { - let capped = "Out of Memory Error: failed to offload data block of size 256.0 KiB \ - (18.9 MiB/19.0 MiB used).\nThis limit was set by the 'max_temp_directory_size' \ - setting.\nYou can adjust this setting, by using (for example) PRAGMA \ - max_temp_directory_size='10GiB'"; - - let configured = spill_cap_hint(capped, true).expect("the cap error must be recognised"); - assert!(configured.contains("DUCKDB_MAX_TEMP_DIRECTORY_SIZE")); - - let default = spill_cap_hint(capped, false).expect("the cap error must be recognised"); - assert!( - !default.contains("DUCKDB_MAX_TEMP_DIRECTORY_SIZE") && default.contains("low on disk"), - "with nothing configured the hint must point at the disk, not at an unset variable" - ); - - // Both must contradict the PRAGMA the engine suggests, which the lock always refuses. - assert!([configured, default] - .iter() - .all(|h| h.contains("PRAGMA suggested above is refused"))); - - assert!(spill_cap_hint("Binder Error: column \"x\" not found", true).is_none()); - } - fn test_ancestor(wid: &str) -> windmill_common::workspaces::DucklakeAncestorAttach { windmill_common::workspaces::DucklakeAncestorAttach { workspace_id: wid.to_string(), diff --git a/backend/windmill-worker/src/duckdb_runner.rs b/backend/windmill-worker/src/duckdb_runner.rs new file mode 100644 index 0000000000..46f5c5cbde --- /dev/null +++ b/backend/windmill-worker/src/duckdb_runner.rs @@ -0,0 +1,626 @@ +//! DuckDB jobs run in `windmill_duckdb_runner`, a process of their own, under the same job +//! isolation as every other language: inside nsjail when the worker jails jobs, under +//! `unshare` when it isolates that way, and as a plain child process otherwise. An engine +//! crash or a cancellation then ends the runner, never the worker. +//! +//! The runner hands back the string the engine call produced, so result collection and error +//! decoding stay in `duckdb_executor`. + +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + process::Stdio, + sync::{LazyLock, Mutex}, + time::Duration, +}; + +use serde::Serialize; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWriteExt}, + process::Command, +}; +use windmill_common::error::{Error, Result}; + +use crate::{ + common::{build_command_with_isolation, resolve_nsjail_tmp_mount_block, DEV_CONF_NSJAIL}, + is_sandboxing_enabled, is_unshare_enabled, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, + NSJAIL_PATH, PATH_ENV, +}; + +const NSJAIL_CONFIG_RUN_DUCKDB_CONTENT: &str = include_str!("../nsjail/run.duckdb.config.proto"); + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum RunnerMode { + /// Inside nsjail: the jail confines the file system. + Jailed, + /// Under `unshare`, as other languages are on such a worker: its own PID namespace and a + /// cleared environment, with the worker user's view of the file system. + Unshare, + /// A plain child process, on a worker that does not isolate jobs. + Plain, +} + +/// Decided once per job, so every connection a job opens runs the same way. +pub(crate) fn runner_mode() -> RunnerMode { + if cfg!(unix) && is_sandboxing_enabled() && NSJAIL_AVAILABLE.is_some() { + RunnerMode::Jailed + } else if is_unshare_enabled() { + // `build_command_with_isolation` logs and runs unisolated when `unshare` is missing, + // as it does for every other language. + RunnerMode::Unshare + } else { + RunnerMode::Plain + } +} + +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RequestMode { + Run, + Prepare, + Install, +} + +#[derive(Serialize, Clone, Copy)] +pub(crate) struct RunnerRequest<'a> { + pub mode: RequestMode, + pub blocks: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + pub job_args: Option<&'a serde_json::value::RawValue>, + pub token: &'a str, + pub base_internal_url: &'a str, + pub w_id: &'a str, + pub memory_limit: &'a str, + pub temp_directory: &'a str, + pub max_result_size: usize, + pub collect_last_only: bool, + pub collect_first_row_only: bool, +} + +/// The runner's answer: the result string (`ERROR ` on failure) and the column order. +pub(crate) struct RunnerOutput { + pub result: String, + pub column_order: Option, +} + +fn runner_path() -> Result { + if let Ok(p) = std::env::var("DUCKDB_RUNNER_PATH") { + return Ok(p); + } + let exe = std::env::current_exe() + .map_err(|e| Error::InternalErr(format!("Could not locate the worker binary: {e}")))?; + let name = format!("windmill_duckdb_runner{}", std::env::consts::EXE_SUFFIX); + let path = exe.with_file_name(&name); + // Cargo runs test binaries from `target//deps`, one level below where the + // worker binary and `build_dev.sh`'s runner sit. + let in_target_dir = exe + .parent() + .filter(|d| d.ends_with("deps")) + .and_then(|d| d.parent()) + .map(|d| d.join(&name)); + let path = match in_target_dir { + Some(p) if !path.exists() && p.exists() => p, + _ => path, + }; + if !path.exists() { + return Err(Error::InternalErr(format!( + "DuckDB jobs need {}, alongside the worker binary. Build it with \ + windmill-duckdb-ffi-internal/build_dev.sh, or point DUCKDB_RUNNER_PATH at it.", + path.display() + ))); + } + Ok(path.to_string_lossy().into_owned()) +} + +/// The jail's own extension cache, separate from the worker user's `~/.duckdb` and mounted +/// read-only. Only [`install_missing_extensions`] writes to it, so a job can neither add to +/// nor replace what the next job loads from it. +fn extension_dir() -> String { + format!("{}/.duckdb/windmill_jail_extensions", HOME_ENV.as_str()) +} + +/// Extensions a job may load inside the jail, where the cache is read-only: the two +/// `setup_duckdb_connection` installs on every connection, the two the platform reaches for +/// without naming them (Parquet data files, the JSON type), and every extension a block +/// installs by name. A `FORCE INSTALL` stays forced: it is the one +/// statement here that must run every time. +fn needed_extensions(blocks: &[String]) -> Vec { + let mut out: Vec = ["httpfs", "azure", "parquet", "json"] + .iter() + .map(|s| format!("INSTALL {s};")) + .collect(); + for block in blocks { + let stmt = block.trim().trim_end_matches(';').trim(); + let mut toks = stmt.split_whitespace().peekable(); + let force = toks.peek().is_some_and(|t| t.eq_ignore_ascii_case("FORCE")); + if force { + toks.next(); + } + if !toks + .next() + .is_some_and(|t| t.eq_ignore_ascii_case("INSTALL")) + { + continue; + } + let Some(name) = toks.next() else { continue }; + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + continue; + } + let name = name.to_ascii_lowercase(); + let force = if force { "FORCE " } else { "" }; + let stmt = match (toks.next(), toks.next(), toks.next()) { + (None, _, _) => format!("{force}INSTALL {name};"), + (Some(f), Some(r), None) + if f.eq_ignore_ascii_case("FROM") && r.eq_ignore_ascii_case("community") => + { + format!("{force}INSTALL {name} FROM community;") + } + _ => continue, + }; + if !out.contains(&stmt) { + out.push(stmt); + } + } + out +} + +static INSTALLED: LazyLock>> = LazyLock::new(Default::default); + +/// Installs, outside the jail, what the job will load inside it. The runner only runs +/// `INSTALL` here, and only of statements built above, so no script SQL and no extension +/// code runs unjailed. Once per extension per worker process. +async fn install_missing_extensions(blocks: &[String], job_dir: &str) -> Result<()> { + let missing: Vec = { + let installed = INSTALLED.lock().unwrap(); + needed_extensions(blocks) + .into_iter() + .filter(|s| s.starts_with("FORCE ") || !installed.contains(s)) + .collect() + }; + if missing.is_empty() { + return Ok(()); + } + let ext_dir = extension_dir(); + tokio::fs::create_dir_all(&ext_dir).await?; + let blocks: Vec = std::iter::once(format!( + "SET extension_directory='{}';", + ext_dir.replace('\'', "''") + )) + .chain(missing.iter().cloned()) + .collect(); + let out = spawn_runner( + RunnerMode::Plain, + &RunnerRequest { + mode: RequestMode::Install, + blocks: &blocks, + job_args: None, + token: "", + base_internal_url: "", + w_id: "", + memory_limit: "", + temp_directory: "", + max_result_size: 0, + collect_last_only: false, + collect_first_row_only: false, + }, + job_dir, + ) + .await?; + if let Some(raw) = out.result.strip_prefix("ERROR ") { + let msg = serde_json::from_str::(raw).unwrap_or_else(|_| raw.to_string()); + return Err(Error::ExecutionErr(format!( + "Installing DuckDB extensions for the jailed runner failed: {msg}" + ))); + } + INSTALLED.lock().unwrap().extend(missing.into_iter().filter(|s| !s.starts_with("FORCE "))); + Ok(()) +} + +/// `FORCE INSTALL` rewrites the cache, which is read-only inside the jail. The worker has +/// already honored it outside, so inside it becomes a plain `INSTALL`, which finds the +/// extension present and does nothing. Same block count, so result collection is unchanged. +fn unforce_installs(blocks: &[String]) -> Vec { + blocks + .iter() + .map(|b| { + let t = b.trim_start(); + match t.split_once(char::is_whitespace) { + Some((first, rest)) + if first.eq_ignore_ascii_case("FORCE") + && rest + .trim_start() + .get(..7) + .is_some_and(|k| k.eq_ignore_ascii_case("INSTALL")) => + { + rest.trim_start().to_string() + } + _ => b.clone(), + } + }) + .collect() +} + +/// The only place a jailed runner can write on the host: its spill directory and its result +/// file. The rest of the job dir is mounted read-only. +fn work_dir(job_dir: &str) -> String { + format!("{job_dir}/duckdb") +} + +/// DuckDB's spill cap, in DuckDB's spelling. The cloud runs one worker per pod on nodes with no +/// per-pod ephemeral-storage limit, so it gets one even when nothing configured it. +fn max_temp_directory_size() -> Option { + match std::env::var("DUCKDB_MAX_TEMP_DIRECTORY_SIZE") { + Ok(v) if !v.trim().is_empty() => Some(v.trim().to_string()), + _ if *windmill_common::worker::CLOUD_HOSTED => Some("6GiB".to_string()), + _ => None, + } +} + +/// Parses a DuckDB size (`6GiB`, `500 MB`, `1024`): 1024-based for `KiB`..`TiB`, 1000-based +/// for `KB`..`TB`. `None` for anything else, which DuckDB then refuses on its own. +fn parse_duckdb_size(s: &str) -> Option { + let s = s.trim(); + let split = s + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(s.len()); + let (num, unit) = s.split_at(split); + let num: f64 = num.parse().ok()?; + let mult: f64 = match unit.trim().to_ascii_lowercase().as_str() { + "" | "b" | "byte" | "bytes" => 1.0, + "kb" => 1e3, + "mb" => 1e6, + "gb" => 1e9, + "tb" => 1e12, + "kib" => 1024.0, + "mib" => 1024.0 * 1024.0, + "gib" => 1024.0 * 1024.0 * 1024.0, + "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0, + _ => return None, + }; + Some((num * mult) as u64) +} + +pub(crate) async fn run( + mode: RunnerMode, + req: &RunnerRequest<'_>, + job_dir: &str, +) -> Result { + let spill_dir = format!("{}/spill", work_dir(job_dir)); + tokio::fs::create_dir_all(&spill_dir).await?; + let req = RunnerRequest { temp_directory: &spill_dir, ..*req }; + if mode != RunnerMode::Jailed { + return spawn_runner(mode, &req, job_dir).await; + } + install_missing_extensions(req.blocks, job_dir).await?; + let blocks = unforce_installs(req.blocks); + spawn_runner(mode, &RunnerRequest { blocks: &blocks, ..req }, job_dir).await +} + +/// Disk the directory actually uses (allocated blocks, so sparse files count for what they +/// hold), without following symlinks: the jailed script controls what is in there. +fn disk_usage(dir: &Path) -> u64 { + let mut total = 0; + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { + continue; + }; + for entry in entries.flatten() { + let Ok(meta) = entry.path().symlink_metadata() else { + continue; + }; + #[cfg(unix)] + { + total += std::os::unix::fs::MetadataExt::blocks(&meta) * 512; + } + #[cfg(not(unix))] + { + total += meta.len(); + } + if meta.is_dir() { + stack.push(entry.path()); + } + } + } + total +} + +/// Resolves once the runner's work dir holds more than `cap`. Inside the jail that dir is the +/// only place the script can write to on the host, and the script can raise DuckDB's own +/// `max_temp_directory_size`, so this is what makes the cap hold. +async fn exceeds(dir: PathBuf, cap: u64) -> u64 { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let d = dir.clone(); + let used = tokio::task::spawn_blocking(move || disk_usage(&d)) + .await + .unwrap_or(0); + if used > cap { + return used; + } + } +} + +/// Kills the runner's process group when the job future is dropped (cancellation, timeout) +/// before the runner exits. Killing the spawned process alone is not enough: `unshare --fork` +/// leaves its child running. +struct KillGroupOnDrop(Option); + +impl Drop for KillGroupOnDrop { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(pgid) = self.0 { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pgid), + nix::sys::signal::Signal::SIGKILL, + ); + } + } +} + +async fn spawn_runner( + mode: RunnerMode, + req: &RunnerRequest<'_>, + job_dir: &str, +) -> Result { + let runner = runner_path()?; + let call_id = uuid::Uuid::new_v4(); + let work_dir = work_dir(job_dir); + tokio::fs::create_dir_all(&work_dir).await?; + let result_path = format!("{work_dir}/result_{call_id}.out"); + let spill_cap = max_temp_directory_size(); + + let mut cmd = match mode { + RunnerMode::Jailed => { + let ext_dir = extension_dir(); + tokio::fs::create_dir_all(&ext_dir).await?; + let bigquery_mount = match std::env::var("GOOGLE_APPLICATION_CREDENTIALS") { + Ok(p) if !p.is_empty() => format!( + "mount {{\n src: \"{p}\"\n dst: \"{p}\"\n is_bind: true\n mandatory: false\n}}" + ), + _ => String::new(), + }; + let config_name = format!("run.duckdb.{call_id}.config.proto"); + tokio::fs::write( + format!("{job_dir}/{config_name}"), + NSJAIL_CONFIG_RUN_DUCKDB_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{RUNNER_PATH}", &runner) + .replace("{EXTENSION_DIR}", &ext_dir) + .replace("{BIGQUERY_CREDENTIALS_MOUNT}", &bigquery_mount) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("#{DEV}", DEV_CONF_NSJAIL) + // Rooted at the work dir rather than the job dir: with a disk-backed /tmp + // the jail's /tmp is a host directory, and inside the work dir the disk cap + // covers it. A RAM-backed /tmp is bounded by its tmpfs size. + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(&work_dir).await, + ), + ) + .await?; + let mut cmd = Command::new(NSJAIL_PATH.as_str()); + cmd.args([ + "--config", + &config_name, + "--", + "/tmp/windmill_duckdb_runner", + ]); + cmd + } + RunnerMode::Unshare => build_command_with_isolation(&runner, &[]), + RunnerMode::Plain => Command::new(&runner), + }; + + cmd.current_dir(job_dir) + .env_clear() + .env("PATH", PATH_ENV.as_str()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + cmd.process_group(0); + // The jail sets its own HOME, over the read-only extension cache. + if mode != RunnerMode::Jailed { + cmd.env("HOME", HOME_ENV.as_str()); + } + // Windows networking (Winsock) needs SystemRoot, so without these every extension install + // and S3 proxy request fails. Empty off Windows. + cmd.envs(crate::WIN_ENVS.iter().cloned()); + #[cfg(windows)] + cmd.env("windir", crate::SYSTEM_ROOT.as_str()); + // DuckDB's default TimeZone follows TZ, as it did in the worker's own environment. + if !crate::TZ_ENV.is_empty() { + cmd.env("TZ", crate::TZ_ENV.as_str()); + } + if let Some(cap) = &spill_cap { + cmd.env("DUCKDB_MAX_TEMP_DIRECTORY_SIZE", cap); + } + for var in [ + "GOOGLE_APPLICATION_CREDENTIALS", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + ] { + if let Ok(v) = std::env::var(var) { + cmd.env(var, v); + } + } + + let mut payload = serde_json::to_value(req).map_err(|e| Error::InternalErr(e.to_string()))?; + payload["result_path"] = serde_json::Value::String(result_path.clone()); + let payload = serde_json::to_vec(&payload).map_err(|e| Error::InternalErr(e.to_string()))?; + + // The engine's memory lives outside the worker, so a native OOM can take the job alone, as + // long as the kernel picks the runner first. Set before exec so nsjail or unshare, and the + // runner they start, inherit it: written after spawn it races that, and the jail's /proc is + // read-only. Only raw syscalls here, between fork and exec. + #[cfg(target_os = "linux")] + { + let adj = windmill_common::worker::JOB_OOM_SCORE_ADJ.to_string(); + unsafe { + cmd.pre_exec(move || { + let fd = nix::libc::open(c"/proc/self/oom_score_adj".as_ptr(), nix::libc::O_WRONLY); + if fd >= 0 { + nix::libc::write(fd, adj.as_ptr().cast(), adj.len()); + nix::libc::close(fd); + } + Ok(()) + }); + } + } + let mut child = cmd.spawn().map_err(|e| { + Error::InternalErr(format!("Could not start the DuckDB runner ({mode:?}): {e}")) + })?; + let mut guard = KillGroupOnDrop(child.id().map(|p| p as i32)); + let mut stdin = child.stdin.take().expect("stdin is piped"); + let write = async move { + stdin.write_all(&payload).await?; + stdin.shutdown().await + }; + let stdout = child.stdout.take().expect("stdout is piped"); + let stderr = child.stderr.take().expect("stderr is piped"); + let cap_bytes = spill_cap.as_deref().and_then(parse_duckdb_size); + let watchdog = async { + match cap_bytes { + Some(cap) if mode == RunnerMode::Jailed => exceeds(PathBuf::from(&work_dir), cap).await, + _ => std::future::pending().await, + } + }; + let (write, status, stdout, stderr) = tokio::select! { + r = async { tokio::join!(write, child.wait(), output_tail(stdout), output_tail(stderr)) } => r, + // Returning drops the child, and with it `kill_on_drop` and `guard` end the runner. + used = watchdog => { + return Err(Error::ExecutionErr(format!( + "DuckDB: this job wrote {} MB to disk, past this worker's limit of {} \ + (DUCKDB_MAX_TEMP_DIRECTORY_SIZE), and was stopped. Raising \ + max_temp_directory_size from the script does not lift it. Spill less (fewer \ + threads, a narrower scan, aggregating before the join), or write large outputs \ + to S3 rather than local files.", + used / 1_000_000, + spill_cap.as_deref().unwrap_or_default() + ))); + } + }; + let status = status?; + guard.0 = None; + let read = read_result(&result_path).await; + let _ = tokio::fs::remove_file(&result_path).await; + + if !status.success() || read.is_err() { + #[cfg(unix)] + let signal = std::os::unix::process::ExitStatusExt::signal(&status); + #[cfg(not(unix))] + let signal: Option = None; + let how = match signal { + Some(sig) => format!("was killed by signal {sig}"), + None => format!("exited with {status}"), + }; + return Err(Error::ExecutionErr(format!( + "The DuckDB engine process {how}. The worker is unaffected.{}{}{}", + write + .err() + .map(|e| format!("\nrequest: {e}")) + .unwrap_or_default(), + if stderr.trim().is_empty() { + String::new() + } else { + format!("\nstderr: {}", stderr.trim()) + }, + if stdout.trim().is_empty() { + String::new() + } else { + format!("\nstdout: {}", stdout.trim()) + }, + ))); + } + let mut column_order = read?; + let header_end = column_order.find('\n').unwrap_or(column_order.len()); + let result = column_order.split_off((header_end + 1).min(column_order.len())); + column_order.truncate(header_end); + Ok(RunnerOutput { column_order: (!column_order.is_empty()).then_some(column_order), result }) +} + +/// The result file sits in a directory the jailed script can write to, so a symlink planted there +/// must not make the worker read some other host file into the job result. +async fn read_result(path: &str) -> std::io::Result { + let mut opts = tokio::fs::OpenOptions::new(); + opts.read(true); + #[cfg(unix)] + opts.custom_flags(nix::libc::O_NOFOLLOW); + let mut s = String::new(); + opts.open(path).await?.read_to_string(&mut s).await?; + Ok(s) +} + +/// Drains a runner pipe keeping only its last few KB. A script controls how much the engine +/// prints (`CALL enable_logging(storage='stdout')`), so buffering it whole would let a jailed +/// job grow the worker's memory without bound. +async fn output_tail(mut r: impl AsyncRead + Unpin) -> String { + const KEEP: usize = 4096; + let mut buf = vec![0u8; 16 * 1024]; + let mut tail: Vec = Vec::with_capacity(2 * KEEP); + loop { + match r.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + tail.extend_from_slice(&buf[..n]); + if tail.len() > KEEP { + tail.drain(..tail.len() - KEEP); + } + } + } + } + String::from_utf8_lossy(&tail).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duckdb_sizes_parse_in_duckdb_units() { + assert_eq!(parse_duckdb_size("6GiB"), Some(6 * 1024 * 1024 * 1024)); + assert_eq!(parse_duckdb_size("500 MB"), Some(500_000_000)); + assert_eq!(parse_duckdb_size("1.5kib"), Some(1536)); + assert_eq!(parse_duckdb_size("1024"), Some(1024)); + // Kubernetes' spelling is not DuckDB's: the engine refuses it, so no cap is derived. + assert_eq!(parse_duckdb_size("6Gi"), None); + assert_eq!(parse_duckdb_size(""), None); + } + + fn strs(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn force_installs_run_outside_and_become_plain_inside() { + let blocks = strs(&[ + "force install httpfs;", + "INSTALL bigquery FROM community;", + "INSTALL x FROM 'http://evil';", + "INSTALL '/tmp/x.so';", + "SELECT 'FORCE INSTALL';", + ]); + assert_eq!( + needed_extensions(&blocks)[4..], + strs(&["FORCE INSTALL httpfs;", "INSTALL bigquery FROM community;"]) + ); + assert_eq!( + unforce_installs(&blocks), + strs(&[ + "install httpfs;", + "INSTALL bigquery FROM community;", + "INSTALL x FROM 'http://evil';", + "INSTALL '/tmp/x.so';", + "SELECT 'FORCE INSTALL';", + ]) + ); + } +} diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index aa1d9ab209..e56334df67 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -45,8 +45,8 @@ mod deno_executor; mod docker_v2; #[cfg(feature = "duckdb")] mod duckdb_executor; -#[cfg(all(feature = "duckdb", feature = "private"))] -mod duckdb_isolation_ee; +#[cfg(feature = "duckdb")] +mod duckdb_runner; mod global_cache; mod go_executor; mod graphql_executor; diff --git a/docker/RHEL8/Dockerfile b/docker/RHEL8/Dockerfile index ea53feabf1..a7eca5fbd8 100644 --- a/docker/RHEL8/Dockerfile +++ b/docker/RHEL8/Dockerfile @@ -93,7 +93,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release RUN mkdir -p /usr/src/app && \ - cp windmill-duckdb-ffi-internal/target/release/libwindmill_duckdb_ffi_internal.so /usr/src/app/ + cp windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner /usr/src/app/ # Runtime Kerberos packages (installed separately from build deps) RUN yum install -y krb5-workstation cyrus-sasl-gssapi diff --git a/docker/RHEL9/Dockerfile b/docker/RHEL9/Dockerfile index ab94df5fc4..cc972884d8 100644 --- a/docker/RHEL9/Dockerfile +++ b/docker/RHEL9/Dockerfile @@ -93,7 +93,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release RUN mkdir -p /usr/src/app && \ - cp windmill-duckdb-ffi-internal/target/release/libwindmill_duckdb_ffi_internal.so /usr/src/app/ + cp windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner /usr/src/app/ # Runtime Kerberos packages (installed separately from build deps) RUN yum install -y krb5-workstation cyrus-sasl-gssapi diff --git a/docs/duckdb-execution.md b/docs/duckdb-execution.md new file mode 100644 index 0000000000..70f9218ee4 --- /dev/null +++ b/docs/duckdb-execution.md @@ -0,0 +1,118 @@ +# How DuckDB jobs run + +A DuckDB job runs in `windmill_duckdb_runner`, a binary built from +`backend/windmill-duckdb-ffi-internal` (`src/bin`) and shipped next to the `windmill` binary. +The worker spawns one runner per DuckDB connection a job opens: the job itself, and the +`on_schema_change=sync` pre-pass. It never loads the engine into its own process. + +The runner gets the same job isolation as every other language. `duckdb_runner::runner_mode` +decides it per job: + +| worker isolates with | runner runs | what it can see | +|---|---|---| +| nsjail | inside `run.duckdb.config.proto` | read-only `/usr`, `/lib`, `/etc`; its own `/tmp`; its job dir read-only, except `duckdb/` (spill + result); a read-only extension cache | +| unshare | under `build_command_with_isolation` | the worker user's file system, its own PID namespace, a cleared environment | +| nothing | as a plain child process | the worker user's file system, a cleared environment | + +There is no SQL-level policy on top. A script can read and write what the jail lets it and load +any extension DuckDB will sign, as a Python job can read the same files and load native code. +Under nsjail its disk use is capped from the worker (below). Network egress is open in every +mode, as it is for the other languages: the S3 proxy, metadata endpoints and internal hosts are +all reachable. + +## What crosses to the runner + +`spawn_runner` writes one JSON request to the runner's stdin, so nothing secret is put on argv or +into the environment: + +- the blocks, already rewritten by the worker: resource `ATTACH`es, DuckLake and datatable + attaches with their credentials inlined, workspace macros; +- the job args, the job token, `base_internal_url` and the workspace id. The runner derives the + S3/GCS secrets for the workspace's S3 proxy from the token, which it reaches over the network; +- `memory_limit` (`DUCKDB_MEMORY_LIMIT`, or 80% of the worker's cgroup), the temp directory (the + job dir), and the result-size cap (`MAX_SQL_RESULT_SIZE`). + +The environment is cleared except `PATH`, `HOME` (outside nsjail), `DUCKDB_MAX_TEMP_DIRECTORY_SIZE`, +`GOOGLE_APPLICATION_CREDENTIALS` (BigQuery `ATTACH`, whose credentials file nsjail mounts) and the +proxy variables. In particular a script cannot use the worker's own `AWS_*` credential chain. + +Under nsjail the job dir is mounted read-only at its host path, so every path the worker wrote +into the blocks (Postgres root certificates) resolves unchanged. The one writable place on the +host is `{job dir}/duckdb/`: the spill directory and the result file, which the worker opens +without following symlinks, since the script can write there. + +The runner writes its answer to a file in that directory: a first line with the last statement's +column order, then either the collected results or `ERROR `. +`finish_run` / `finish_prepare` in `duckdb_executor.rs` turn that into the job result. + +## Failure, cancellation and memory + +- **A crash fails the job, never the worker.** A runner that exits without writing its result + becomes `The DuckDB engine process … The worker is unaffected.` with the tail of its stderr. + An engine `std::terminate` (for example a remote `temp_directory` that has to spill) and a + panic in result rendering both land here. +- **Cancellation and timeouts kill the engine.** The executor drops the job future, and + `KillGroupOnDrop` kills the runner's whole process group. Killing the spawned process alone is + not enough: `unshare --fork` leaves its child running. +- **The kernel OOM-kills the runner, not the worker.** `JOB_OOM_SCORE_ADJ` is written in + `pre_exec`, before nsjail or `unshare` is exec'd, so the runner inherits it. Written after spawn + it would race their fork, and the jail's `/proc` is read-only. Within the job, DuckDB bounds + itself through `memory_limit` and spills past it. +- **Runner output is bounded.** stdout/stderr are drained keeping only the last 4 KB: a script + controls how much the engine prints (`CALL enable_logging(storage='stdout')`). + +## Extensions + +Outside nsjail the runner uses the worker user's `~/.duckdb/extensions` and installs whatever a +script names. + +Under nsjail it gets its own cache, `~/.duckdb/windmill_jail_extensions`, mounted read-only. +Only the worker writes to it: before a jailed run it starts the runner unjailed in `install` +mode, with only `INSTALL` statements it built itself, for `httpfs`, `azure`, `parquet`, `json` +and every extension a block installs by name. That runs no script SQL and loads nothing, so no +extension code runs outside the jail. It happens once per extension per worker process, except +for `FORCE INSTALL`, which is honored every time and becomes a plain `INSTALL` inside the jail. +A job can therefore neither add to nor replace what the next job loads from that cache. An +extension used without being named and outside those four (`icu` behind `SET TimeZone`, say) +fails on the read-only mount: name it with `INSTALL`. + +A script can still point `extension_directory` somewhere writable and install into it. That only +affects its own connection, and is no more than a Python job could do. + +## The engine + +The runner links duckdb-rs from crates.io, pinned with `=` so `cargo update` cannot move the +engine. The engine loads the **prebuilt** extensions from `extensions.duckdb.org`, compiled for the +exact release it reports, so an engine bump is a version change in `Cargo.toml` and nothing else. + +Confinement belongs to the job isolation, not to engine settings. DuckDB spills through the same +file system a query reads with, so `disabled_filesystems='LocalFileSystem'` also stops every query +that outgrows `memory_limit`, and no combination of stock settings gives "spill, but no other local +file access". Fencing the engine at the SQL level again would need a patched engine to keep +spilling. + +## Bounding disk use + +The cap is `DUCKDB_MAX_TEMP_DIRECTORY_SIZE`, or `6GiB` on a `CLOUD_HOSTED` worker that does not +set it. The cloud runs one worker per pod on nodes with no per-pod ephemeral-storage limit, and a +worker pod already holds several GiB of runtime caches, so the cap is sized against a mostly full +node; re-measure before raising it. Unset elsewhere, DuckDB's own default applies: 90% of the +temp volume's free space. + +It is applied twice: + +- **DuckDB's `max_temp_directory_size`**, set on every connection, so an ordinary query that + outgrows it fails with the engine's `failed to offload data block … max_temp_directory_size` + error and cleans up its temp files. +- **A worker-side watchdog**, under nsjail. A script can raise the engine setting, so on its own + it is only a default. The worker sums the allocated size of `{job dir}/duckdb/` once a second + and kills the runner when it passes the cap, whatever wrote the bytes: spill, `COPY … TO`, or + the result file. With a disk-backed jail `/tmp` (`NSJAIL_TMP_BACKING=disk`) that `/tmp` lives +inside the same directory, so it counts too; a RAM-backed one is bounded by its tmpfs size. +Enforcement lags by up to a second of writing. Outside nsjail the script can + write anywhere the worker user can, as other languages can, so there the cap stays a default. + +The value is DuckDB's spelling, not Kubernetes': `KiB`/`MiB`/`GiB`/`TiB` for 1024-based units, +`KB`/`MB`/`GB`/`TB` for 1000-based. An unparseable value fails every DuckDB job on the worker +rather than being ignored. `DUCKDB_MEMORY_LIMIT` is spelled the same way, and the two are set in +separate statements so a parse error names the right variable. diff --git a/docs/duckdb-isolation.md b/docs/duckdb-isolation.md deleted file mode 100644 index fb85085b2c..0000000000 --- a/docs/duckdb-isolation.md +++ /dev/null @@ -1,223 +0,0 @@ -# DuckDB isolation and the patched engine - -DuckDB runs **in-process** in the worker, through the FFI cdylib in -`backend/windmill-duckdb-ffi-internal`. It is therefore the one language that the -nsjail/unshare isolation cannot confine: there is no child process to jail. On a worker -that isolates jobs, `windmill-worker/src/duckdb_isolation_ee.rs` mirrors that policy at -the connection level instead, rewriting the block list handed to the engine so it starts -with three irreversible settings: - -```sql -SET lock_temp_directory=true; -SET disabled_filesystems='LocalFileSystem'; -SET allow_community_extensions=false; -``` - -`disabled_filesystems` is the fence. `lock_temp_directory` is what keeps out-of-core -execution working behind it, and it is **not a stock DuckDB setting** — see below. - -## Why the engine had to change - -DuckDB spills through the same file system a query would use to read `/etc/hostname`, so -`disabled_filesystems='LocalFileSystem'` also stops the buffer manager. Every query whose -working set outgrows `memory_limit` then fails instead of going out-of-core. No -combination of stock settings gives "spill, but no other local file access": - -- `allowed_directories` / `allowed_paths` enforce nothing while `enable_external_access` - is true — `DBConfig::CanAccessFile` returns early. -- Turning `enable_external_access` off does preserve spilling (startup adds the temp - directory to `allowed_directories`), but the postgres and mysql extensions refuse to - `ATTACH` on that flag alone, with no allowlist entry able to re-permit it. That would - take DuckLake catalogs and datatables down with it. -- A remote `temp_directory` is impossible: DuckDB cannot open an HTTP file for both - reading and writing, and the attempt aborts the process. - -The engine is also not arranged the way the layering suggests. `FileSystem::GetLocal(db)` -looks like an escape hatch — it is what secret storage and extension installation use — -but `LocalDatabaseFileSystem::GetFileSystem()` re-applies the `disabled_filesystems` -check by hand, so it is not one. (That is also why extension autoload broke behind the -fence in DuckDB 1.5.3, which `EXTENSION_ALLOWLIST` and `PRELOAD_EXTENSIONS` exist to work -around.) The buffer manager, meanwhile, reaches its temp files through -`FileSystem::GetFileSystem(db)`, an opener-wrapped virtual file system, so both the path -allowlist and the disable apply to it. - -## What the patches add - -`lock_temp_directory` (BOOLEAN, default false, global, irreversible). Setting it: - -1. fixes `temp_directory` in place — it can no longer be modified, and the setting itself - cannot be unset or reset; -2. fixes `max_temp_directory_size` the same way, so the ceiling on what may be written - there cannot be raised either; -3. routes the buffer manager's temporary files through a local file system that - `disabled_filesystems` does not apply to. - -The halves are what make it safe as a set. The exemption is only sound because the -directory can no longer be redirected: without (1), a script could point `temp_directory` -at an arbitrary path and get a blind write primitive through the exemption. And it is only -bounded because of (2): DuckDB accepts `SET max_temp_directory_size='100GB'` from any -statement, so a cap a script can lift is not a cap. -`allowed_directories` still applies either way. - -The isolation transform therefore emits `SET lock_temp_directory=true` **before** -`disabled_filesystems`, while the local file system is still reachable, and after the FFI -has already pointed `temp_directory` at the job dir -(`configure_duckdb_resource_limits`). Everything a script authored runs after all three -settings, so a script can neither move the temp directory nor raise the cap on it. - -## Bounding the spill - -`configure_duckdb_resource_limits` sets `max_temp_directory_size` from -`DUCKDB_MAX_TEMP_DIRECTORY_SIZE`, immediately before it takes the lock. Unset — the -self-hosted default — leaves DuckDB's own default of 90% of the temp volume's free space, -so nothing changes for an install that does not opt in. The cloud sets `6GiB` per worker -pod (`cloudee/cloud/cloud.yml` in `infra-aws`); those pods run a single worker each, so it -is effectively a per-pod cap and is not divided by `NUM_WORKERS`. - -Those pods carry no `ephemeral-storage` limit, so what the cap has to stay clear of is the -node's eviction threshold rather than a per-pod one, and the budget is tighter than the -node's size suggests: a worker pod already holds 4.7-6.9 GiB of language-runtime caches and -job dirs before any spill, so a ~100 GB node running 5-6 of them sits at 45-60 GB used at -rest. Every pod on the fullest node spilling to its cap at the same instant would land near -the kubelet's default `nodefs.available<10%`. That is the tail, not the common case, and it -is still bounded where the previous behaviour was not — one query could take all the free -space by itself — but the cap is sized against a mostly-full node, not an empty one. Re-measure -before raising it, and re-measure if the runtime-cache baseline grows. - -It is read from the environment rather than passed across the FFI: the cdylib is versioned -separately from the worker and agent workers ship it out of band, so a signature change -would mean an ABI bump and a coordinated redeploy for what is a config value. - -The value is DuckDB's spelling, not Kubernetes': `KiB`/`MiB`/`GiB`/`TiB` for 1024-based -units, `KB`/`MB`/`GB`/`TB` for 1000-based. Kubernetes' `6Gi` is not a unit DuckDB knows, -and an unparseable value fails every DuckDB job on the worker rather than being ignored — -a cap that quietly disappeared would be worse. `DUCKDB_MEMORY_LIMIT` is spelled the same -way and DuckDB's parse error says only "memory", so the cap's `SET` is issued on its own -statement: batched together, a bad value in either would produce the same message and name -neither. - -Enforcement is graceful. A query that needs more spill than the cap fails with -`Out of Memory Error: failed to offload data block of size … This limit was set by the -'max_temp_directory_size' setting.`, its temp files are removed, and the worker carries on. -Size it from measurement, not from the data: DuckDB compresses temp files, so the logical -spill a given cap permits is well above the number set. - -That engine message goes on to suggest `PRAGMA max_temp_directory_size='10GiB'`, which the -lock refuses — advice that was actionable before the cap was frozen and is now a dead end -ending in a second, unrelated-looking permission error. `decode_ffi_error` -(`duckdb_executor.rs`) appends a correction whenever the message appears, on every worker -rather than only isolating ones, since the lock is taken on every connection. It matches -DuckDB's wording verbatim, which -`spilling_past_the_configured_cap_fails_the_query_and_cleans_up` asserts against real engine -output so an engine bump that rewords it fails the test instead of silently dropping the -correction. - -The engine emits that same message for **its own default cap**, so which limit was hit -cannot be read off the message: with `DUCKDB_MAX_TEMP_DIRECTORY_SIZE` unset it means the -worker is low on disk, not that anyone configured a ceiling. `spill_cap_hint` takes the -answer from the worker's config instead and says whichever is true — naming the env var on a -worker that never set it would point the reader at a knob that does not exist. - -## Where the patched engine comes from - -`backend/windmill-duckdb-ffi-internal` depends on -[`windmill-labs/duckdb-rs`](https://github.com/windmill-labs/duckdb-rs), branch -`windmill/duckdb-1.5.5`, pinned by rev. That fork is duckdb-rs 1.10505.0 (DuckDB 1.5.5) -plus the windmill commits; `duckdb.tar.gz` is left byte-identical to upstream's and -`crates/libduckdb-sys/windmill/0*.patch` are applied to the extracted sources at build -time, in order, so `git diff` against `duckdb/duckdb-rs` shows the whole delta as text. -`0001` adds the setting and the exemption, `0002` extends the lock to -`max_temp_directory_size`. `crates/libduckdb-sys/windmill/README.md` in the fork carries -the rebase recipe. - -### The patches may not move a struct member - -The engine loads the **prebuilt** extensions from `extensions.duckdb.org` — httpfs, azure, -postgres, ducklake, parquet, and the rest of `EXTENSION_ALLOWLIST` — which are compiled -against the struct layouts of the release the engine claims to be. A patch that shifts a -member those extensions reach breaks them, and not loudly: the extension reads the -neighbouring field and `LOAD httpfs` **blocks forever** rather than failing, so every -DuckDB job hangs until its timeout. - -The first revision of `0001` declared its new `DatabaseInstance` member next to -`local_db_file_system`, which pushed `create_api_v1` — the extension C-API entry point — -8 bytes along, and every extension load hung. It is declared last for that reason, and -`lock_temp_directory` is declared at the end of `DBConfigOptions`' run of bools so it takes -a byte of padding the struct already had. - -The invariant is **no pre-existing member changes offset**, which is strictly stronger than -`sizeof` holding — the first attempt at that field went in next to `temporary_directory`, -kept `sizeof(DBConfigOptions)` at 920 by consuming interior padding, and still slid four -bools along by a byte each. A rebase must diff the full record layouts -(`clang++ -Xclang -fdump-record-layouts`, recipe in the fork's README), not the sizes. -`prebuilt_extensions_still_load` in the FFI crate is the end-to-end guard — a timeout, not -an assertion, because the failure mode is a hang — but it covers extension *entry*, not -every field an extension reads, so it does not substitute for the layout diff. - -Alternatives considered and rejected: vendoring a patched `libduckdb-sys` in this repo -would put a 6 MB binary tarball into windmill's history on every engine bump and would -need the FFI builder stage in `Dockerfile` to copy more than -`backend/windmill-duckdb-ffi-internal`; linking a prebuilt engine through -`DUCKDB_LIB_DIR` would drop the `bundled` build, so every developer and every CI job -would need a patched `libduckdb` on the box before `build_dev.sh` could work. - -## What stops the patches disappearing silently - -An unpatched engine compiles, links and passes every existing test. Three things catch it: - -- **The fork's build script** asserts each patch's marker string is present in the patched - sources after applying it, and panics if one no longer applies. -- **`patched_engine_tests` in the FFI crate** runs a query that must spill, first - without the lock (asserting the fence stops it, which is what proves the query really - spills) and then with it (asserting it succeeds); it also pins that the lock leaves - neither `temp_directory` nor `max_temp_directory_size` settable, and that a configured - connection stops at its cap. It fails outright on a stock engine, where the `SET` is an - unrecognized parameter. CI runs it via the FFI crate's own `cargo test` step in - `backend-test.yml`. -- **The transform itself fails closed**: on a stock engine `SET lock_temp_directory=true` - errors, so every DuckDB job on an isolating worker fails immediately rather than - quietly losing the ability to spill. - -## Upstream - -Both patches are written against `duckdb/duckdb` `v1.5.5` in the shape upstream takes -changes. Besides the engine sources they carry `src/common/settings.json` (the settings -generator's input, regenerated with `scripts/generate_settings.py` and formatted with -clang-format 11.0.1), two sqllogictests under `test/sql/settings/`, and one line in -`test/api/test_reset.cpp` — that test requires every setting to survive a `RESET`, so an -irreversible one has to join the exclusion list `disabled_filesystems` and -`lock_configuration` are already on. They are `git format-patch` files, so they apply with -`git am`: - -```bash -git clone https://github.com/duckdb/duckdb.git && cd duckdb && git checkout v1.5.5 -git am .../crates/libduckdb-sys/windmill/0*.patch -GEN=ninja make && ./build/release/test/unittest "test/sql/settings/lock_temp_directory*" -``` - -`0001` is filed as -**[duckdb/duckdb#24694](https://github.com/duckdb/duckdb/pull/24694)**, against `main` -rather than `v1.5.5`. Filing was a human decision on purpose: their `CONTRIBUTING.md` asks -contributors not to submit LLM-generated pull requests, to discuss the change on GitHub -first, and to run CI on a fork before opening one. - -**`0002` is windmill-only and is not on #24694.** It is kept as a separate commit rather -than folded into `0001` for exactly that reason: `0001` stays byte-identical to what the -open PR carries, and `0002` applies to it verbatim as a second commit whenever a human -chooses to push it. That is the one sanctioned divergence; when it is pushed, say so here -and in the fork's `windmill/README.md`, and the "same change" rule below covers it too. - -**[duckdb/duckdb#24695](https://github.com/duckdb/duckdb/issues/24695)** states the -underlying problem without prescribing a fix, and is where a different design would land: -`lock_temp_directory` is one shape, and an enforceable `allowed_directories` would solve it -equally well. Watch the issue rather than the PR for whether this fork can be retired. - -`0001` and #24694 are the same change and must stay that way. They differ only -where the base version forces it: the `DUCKDB_SETTING_ALIAS` indices, `vfs` versus -`db.config.file_system` in `FileSystem::GetLocal`, and the sqllogictest directory -placeholder. Nothing else should diverge. Note that the build applies neither the `test/` -hunks nor `settings.json` (`NOT_IN_TARBALL` in `build_windmill_patch.rs`), so drift in -those is invisible here and only surfaces when #24694 is rebased. - -Until it lands in a released DuckDB the fork is the delivery mechanism, and the exit path -is to delete `crates/libduckdb-sys/windmill/` and go back to the crates.io crate. diff --git a/docs/external-instance-datatables.md b/docs/external-instance-datatables.md index acfb93b90a..38e810b03d 100644 --- a/docs/external-instance-datatables.md +++ b/docs/external-instance-datatables.md @@ -83,6 +83,6 @@ Worth knowing while testing: - A worker needs the `postgresql` and `duckdb` tags for those jobs (`update config set config = jsonb_set(config, '{worker_tags}', …) where name = 'worker__default'`). -- DuckDB jobs load `libwindmill_duckdb_ffi_internal.so` by name, so a binary built into its own - `CARGO_TARGET_DIR` needs that library on `LD_LIBRARY_PATH`. +- DuckDB jobs run `windmill_duckdb_runner` from next to the worker binary, so a binary built + into its own `CARGO_TARGET_DIR` needs `DUCKDB_RUNNER_PATH` pointed at the runner. - Setup holds the lifecycle lock for its whole run, so a settings save during it waits. From 1f1df1254ebc417a8344801b571c4be8e98636c4 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 15 Oct 2026 17:21:28 +0200 Subject: [PATCH 48/49] fix: relock scripts and flow steps whose code changed on deploy (#11794) * fix: relock scripts deployed from a draft that carries the deployed lock Co-Authored-By: Claude Opus 5.5 (1M context) * chore: reword fetchDeployed lock comment Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop the lock of flow steps whose code the chat changed Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop stale flow step locks at deploy, from drafts and the flow editor Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep locks of copied flows and never block a deploy on the lock check Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the lock of a renamed or duplicated flow step with unchanged code Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/FlowBuilder.svelte | 11 ++++ .../copilot/chat/global/core.test.ts | 33 +++++++++++ .../components/copilot/chat/global/core.ts | 10 +++- .../components/flows/staleStepLocks.test.ts | 25 ++++++++ .../lib/components/flows/staleStepLocks.ts | 33 +++++++++++ frontend/src/lib/utils_draft_deploy.test.ts | 58 +++++++++++++++++++ frontend/src/lib/utils_draft_deploy.ts | 16 ++++- 7 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/components/flows/staleStepLocks.test.ts create mode 100644 frontend/src/lib/components/flows/staleStepLocks.ts diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 17f984bac9..f533fcc1f0 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -49,6 +49,7 @@ import WorkspaceScriptSettingsDrawer from './flows/content/WorkspaceScriptSettingsDrawer.svelte' import FlowEditorDrawer from './flows/content/FlowEditorDrawer.svelte' import { dfs as dfsApply } from './flows/dfs' + import { dropStaleStepLocks } from './flows/staleStepLocks' import { claimLinkedToolsFetch, invalidateLinkedToolsFetches, @@ -627,6 +628,16 @@ } } + // The editor clears a step's lock when its code is edited, but a draft written elsewhere + // can hold a step whose code changed and whose lock did not. A failed lookup skips the + // check rather than blocking the deploy. + const liveFlowValue = isNewFlow + ? undefined + : await FlowService.getFlowByPath({ workspace: opWorkspace!, path: initialPath }) + .then((f) => f.value) + .catch(() => undefined) + dropStaleStepLocks(flow.value, liveFlowValue) + if (isNewFlow) { await FlowService.createFlow({ workspace: opWorkspace!, diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index ba71ced69b..a07b0102ca 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -4773,6 +4773,39 @@ describe('global AI tools', () => { ).resolves.toBe(code) }) + // The worker reuses a non-empty step lock, so an edited step keeping its lock deploys + // against the old dependencies. + it('set_flow_module_code drops the lock of the edited step only', async () => { + const step = (id: string) => ({ + id, + value: { + type: 'rawscript', + language: 'python3', + content: 'import requests', + lock: 'requests==2.0', + input_transforms: {} + } + }) + seedBackendDraft('flow', 'f/flows/locked', { + path: 'f/flows/locked', + summary: 'f', + description: '', + value: { modules: [step('edited'), step('untouched')] }, + schema: {} + }) + + await callGlobalTool('set_flow_module_code', { + path: 'f/flows/locked', + module_id: 'edited', + code: 'import yaml' + }) + + const [edited, untouched] = getBackendDraft('flow', 'f/flows/locked').value.modules + expect(edited.value.content).toBe('import yaml') + expect(edited.value).not.toHaveProperty('lock') + expect(untouched.value.lock).toBe('requests==2.0') + }) + it('warns about every empty rawscript body (top-level, nested, preprocessor, failure) and skips populated ones', async () => { const result = JSON.parse( await callGlobalTool('write_flow', { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 3b6064dee6..6f762a4a22 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -52,6 +52,7 @@ import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts' import { resolveLinkedAgentTools } from '$lib/components/flows/flowState' import { enabledToolNames, type AgentTool } from '$lib/components/flows/agentToolUtils' import { evalValue } from '$lib/components/flows/utils.svelte' +import { dropStaleStepLocks } from '$lib/components/flows/staleStepLocks' import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { @@ -5804,7 +5805,12 @@ type ScriptDraftArgs = { const SCRIPT_SPEC: WriteSpec = { probe: (workspace, path) => ScriptService.existsScriptByPath({ workspace, path }), fetchDeployed: async (workspace, path) => { - const existing = await ScriptService.getScriptByPath({ workspace, path }) + // The deployed lock describes the deployed content, not the draft's; the deploy recomputes it. + const { + lock: _lock, + lock_error_logs: _lockErrorLogs, + ...existing + } = await ScriptService.getScriptByPath({ workspace, path }) return { ...(existing as unknown as NewScript), parent_hash: existing.hash } }, buildDraft: async (base, args, path) => { @@ -5903,6 +5909,8 @@ const FLOW_SPEC: WriteSpec = { } } } + // Also checked at deploy; dropped here too because a test run of the draft reads the locks. + dropStaleStepLocks(value, base?.value) return base ? { ...structuredClone(base), diff --git a/frontend/src/lib/components/flows/staleStepLocks.test.ts b/frontend/src/lib/components/flows/staleStepLocks.test.ts new file mode 100644 index 0000000000..ae12860391 --- /dev/null +++ b/frontend/src/lib/components/flows/staleStepLocks.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { dropStaleStepLocks } from './staleStepLocks' + +const step = (id: string, content: string, lock?: string) => + ({ id, value: { type: 'rawscript', language: 'python3', content, lock } }) as any + +describe('dropStaleStepLocks', () => { + // A rename or duplicate gives the step a new id; its code and lock still match. + it('keeps the lock of a renamed or duplicated step whose code is unchanged', () => { + const base = { modules: [step('a', 'import requests', 'requests==2.0')] } + const value = { + modules: [ + step('a', 'import requests', 'requests==2.0'), + step('a_copy', 'import requests', 'requests==2.0'), + step('a_edited_copy', 'import yaml', 'requests==2.0') + ] + } + dropStaleStepLocks(value as any, base as any) + expect(value.modules.map((m) => m.value.lock)).toEqual([ + 'requests==2.0', + 'requests==2.0', + undefined + ]) + }) +}) diff --git a/frontend/src/lib/components/flows/staleStepLocks.ts b/frontend/src/lib/components/flows/staleStepLocks.ts new file mode 100644 index 0000000000..2b9cacfb72 --- /dev/null +++ b/frontend/src/lib/components/flows/staleStepLocks.ts @@ -0,0 +1,33 @@ +import type { FlowModule, FlowValue } from '$lib/gen' +import { forEachFlowModule } from './dfs' + +/** + * Drops, in place, the lock of every inline step whose code differs from the same step in + * `base`. The worker reuses any non-empty step lock, so a step whose code changed but kept its + * lock runs against the old dependencies. Unchanged steps keep theirs, so a deploy does not + * re-resolve (and possibly upgrade) what nobody touched. A step id `base` lacks (a renamed or + * duplicated step) keeps its lock only when some `base` step has the same code. Without a base + * nothing is dropped: a copied or imported flow carries locks that match its code. + */ +export function dropStaleStepLocks(value: FlowValue, base: FlowValue | undefined): void { + if (!base) return + const allModules = (v: FlowValue) => + [...(v.modules ?? []), v.preprocessor_module, v.failure_module].filter( + (m): m is FlowModule => m != null + ) + const contentOf = (m: FlowModule) => (m.value as { content?: unknown }).content + const baseContentById = new Map() + const baseContents = new Set() + forEachFlowModule(allModules(base), (m) => { + baseContentById.set(m.id, contentOf(m)) + baseContents.add(contentOf(m)) + }) + forEachFlowModule(allModules(value), (m) => { + const step = m.value as { content?: unknown; lock?: unknown } + if (step.lock == undefined) return + const unchanged = baseContentById.has(m.id) + ? baseContentById.get(m.id) === step.content + : baseContents.has(step.content) + if (!unchanged) delete step.lock + }) +} diff --git a/frontend/src/lib/utils_draft_deploy.test.ts b/frontend/src/lib/utils_draft_deploy.test.ts index 5f503989c8..3164a05bfc 100644 --- a/frontend/src/lib/utils_draft_deploy.test.ts +++ b/frontend/src/lib/utils_draft_deploy.test.ts @@ -124,6 +124,64 @@ describe('deployDraft preserves on_behalf_of', () => { }) }) +// The backend skips the dependency job when `lock` is set, so a draft that inherited the +// deployed lock would deploy new content against the old lockfile. +describe('deployDraft: script lock', () => { + beforeEach(() => vi.clearAllMocks()) + + it('never sends the draft lock, so the backend relocks', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + hash: 'v1', + draft: { + path: 'f/admin/fetch', + content: 'import requests', + lock: 'certifi==2024.1.1', + lock_error_logs: 'old error' + } + } as any) + + await deployDraft('script', 'f/admin/fetch', 'ws') + const body = vi.mocked(ScriptService.createScript).mock.calls[0][0].requestBody + expect(body).not.toHaveProperty('lock') + expect(body).not.toHaveProperty('lock_error_logs') + }) + + it('flow: drops the lock of a step whose code differs from the deployed one only', async () => { + const step = (id: string, content: string) => ({ + id, + value: { type: 'rawscript', language: 'python3', content, lock: 'requests==2.0' } + }) + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + value: { modules: [step('edited', 'import requests'), step('kept', 'import requests')] }, + draft: { + path: 'f/admin/flow', + value: { modules: [step('edited', 'import yaml'), step('kept', 'import requests')] } + } + } as any) + + await deployDraft('flow', 'f/admin/flow', 'ws') + const [edited, kept] = vi.mocked(FlowService.updateFlow).mock.calls[0][0].requestBody.value + .modules as any[] + expect(edited.value).not.toHaveProperty('lock') + expect(kept.value.lock).toBe('requests==2.0') + }) + + // A never-deployed flow is often a copy or an import, whose locks match its code. + it('flow: keeps every lock when nothing is deployed to compare against', async () => { + const step = { id: 'copied', value: { type: 'rawscript', content: 'x', lock: 'requests==2.0' } } + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + no_deployed: true, + value: { modules: [step] }, + draft: { path: 'f/admin/copy', value: { modules: [step] } } + } as any) + + await deployDraft('flow', 'f/admin/copy', 'ws', { draftOnly: true }) + const [copied] = vi.mocked(FlowService.createFlow).mock.calls[0][0].requestBody.value + .modules as any[] + expect(copied.value.lock).toBe('requests==2.0') + }) +}) + // The resource branch reads the item again when the deploy lands, and falls back to the deployed // row when the draft has gone. That row keeps its value under `value` and carries no `args` at // all, so reading it as a draft (`value: d.args ?? {}`) would replace a live resource with `{}`. diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 44dec7f124..b17b17cbc7 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -46,6 +46,7 @@ import { setLocalDraftHint } from '$lib/localDraftHints.svelte' import { userStore } from '$lib/stores' import { deployTriggers, type Trigger } from '$lib/components/triggers/utils' import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' +import { dropStaleStepLocks } from '$lib/components/flows/staleStepLocks' import { saveHttpRouteFromCfg } from '$lib/components/triggers/http/utils' import { saveWebsocketTriggerFromCfg } from '$lib/components/triggers/websocket/utils' import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils' @@ -469,7 +470,15 @@ export async function deployDraft( const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any const d = r.draft ?? r // Drop editor-only / server-managed keys; deploy as a real (non-draft) version. - const { draft_triggers: draftTriggers, draft_only: _o, ...rest } = d + // A draft seeded from the deployed script carries its `lock`, and the backend skips + // the dependency job whenever `lock` is set, so the new content would keep the old lock. + const { + draft_triggers: draftTriggers, + draft_only: _o, + lock: _lock, + lock_error_logs: _lockErrorLogs, + ...rest + } = d const scriptPath = d.path ?? path // Deploy at the draft's path so a rename in the draft is honored (same as // the editor: createScript at the new path with parent_hash links lineage). @@ -490,6 +499,9 @@ export async function deployDraft( } else if (kind === 'flow') { const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any const d = r.draft ?? r + const value = d.value ? structuredClone(d.value) : d.value + // With nothing deployed the server fills `r.value` from the draft itself. + if (value) dropStaleStepLocks(value, draftOnly || r.no_deployed ? undefined : r.value) const requestBody = { // Deploy at the draft's intended path: flow/app/raw-app drafts keep the // user-typed path in `draft_path` (a never-deployed item is parked at a @@ -498,7 +510,7 @@ export async function deployDraft( path: d.draft_path ?? d.path ?? path, summary: d.summary ?? '', description: d.description ?? '', - value: d.value, + value, schema: d.schema, tag: d.tag, dedicated_worker: d.dedicated_worker, From 7da234481333fcbdc348215589e2d3bfe5394b4a Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:21:56 +0200 Subject: [PATCH 49/49] fix: let raw app SDK tokens read the viewer's identity without a toggle (#11790) * feat: grant raw app SDK tokens identity access without a toggle * test: pin the implicit identity scope on raw app SDK tokens * refactor: let raw app SDK tokens reach whoami at the route check --- backend/windmill-api-auth/src/scopes.rs | 12 +++++-- backend/windmill-api/openapi.yaml | 3 +- backend/windmill-api/src/apps.rs | 13 +++++-- .../src/mcp/auto_generated_endpoints.rs | 8 +++-- cli/src/guidance/skills.gen.ts | 4 +-- .../apps/editor/AppEditorHeaderDeploy.svelte | 10 +++--- .../raw_apps/RawAppSdkConsent.svelte | 4 +-- .../lib/components/raw_apps/sdkScopes.test.ts | 36 +++++++++++-------- .../src/lib/components/raw_apps/sdkScopes.ts | 26 +++++++++----- frontend/src/lib/mcpEndpointTools.ts | 8 +++-- system_prompts/auto-generated/prompts.ts | 2 +- .../auto-generated/skills/raw-app/SKILL.md | 4 +-- system_prompts/base/raw-app.md | 4 +-- 13 files changed, 88 insertions(+), 46 deletions(-) diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 1b605e70eb..bdbab1bfe7 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -73,7 +73,12 @@ pub fn check_route_access( .to_string(), )); } - if required_domain == ScopeDomain::Users && suffix != "users/whoami" { + if required_domain == ScopeDomain::Users { + // Whoami returns what the bundle already has in `window.ctx`, so it + // needs no declared scope. + if suffix == "users/whoami" { + return Ok(()); + } return Err(Error::PermissionDenied( "Access denied. A raw app frontend SDK token can only read the viewer's own identity." .to_string(), @@ -418,8 +423,9 @@ pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result) -> bool { diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index db7280dbbf..e9b5732f0a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -36871,7 +36871,8 @@ components: the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, - flow_conversations:write). + flow_conversations:write). Every such token can call whoami without + consent, whether `users:read` is declared or not. ListableApp: type: object diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 1dcee82e5c..453422403d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -6779,8 +6779,8 @@ mod embed_token_tests { "POST", ), ("/api/w/test/jobs/restart/f/some-uuid", "POST"), - // `users:read` is presented to the viewer as "read your identity", so - // the workspace member directory must stay out of reach. + // `users:read` reaches whoami only: the workspace member directory must + // stay out of reach. ("/api/w/test/users/list", "GET"), ("/api/w/test/users/list_usage", "GET"), ("/api/w/test/users/username_to_email/admin", "GET"), @@ -6791,6 +6791,15 @@ mod embed_token_tests { "SDK token should deny {method} {path}" ); } + + // Whoami needs no declared scope. + let jobs_only = [ + "jobs:run".to_string(), + windmill_api_auth::scopes::RAW_APP_SDK_SENTINEL.to_string(), + ]; + assert!( + check_scopes_for_route(Some(&jobs_only[..]), "/api/w/test/users/whoami", "GET").is_ok() + ); } /// The token carries path-scoped `apps:run:` and `apps:read:` diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index f449902f97..dd3c83a08d 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -599,6 +599,10 @@ pub fn all_tools() -> Vec { "include_draft_only": { "type": "boolean", "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n" + }, + "starred_only": { + "type": "boolean", + "description": "Only the `ai_agent` resources the caller starred" } }, "required": [] @@ -1336,7 +1340,7 @@ is, a different one moves it there and archives the old path"), "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } } @@ -1453,7 +1457,7 @@ is, a different one moves it there and archives the old path"), "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } }, diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 8d33007984..3b7bfa2298 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6926,14 +6926,14 @@ An app's custom URL is not in \`raw_app.yaml\`: each workspace has its own file ## Sandbox isolation and frontend API access -New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in \`window.ctx\`, and whoami works with any of them. Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry \`Origin: null\`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or \`localStorage\`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. In \`raw_app.yaml\`: \`\`\`yaml sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] \`\`\` A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise. diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index dd6c163edd..41e0c5c3f2 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -24,7 +24,7 @@ type OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte' import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' - import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes' + import { FRONTEND_SDK_SCOPES, sdkScopesToConfirm } from '$lib/components/raw_apps/sdkScopes' import { logFeatureUsage } from '$lib/utils/featureUsage' import { useOperatingUser, @@ -436,7 +436,7 @@ tooltip="Each viewer approves these scopes before the app runs. The app's code, or an XSS bug in it, can use them as that viewer." > {#snippet badge()} - {#if policy.frontend_sdk_scopes?.length} + {#if sdkScopesToConfirm(policy.frontend_sdk_scopes ?? []).length}
    {#each FRONTEND_SDK_SCOPES.filter( (s) => policy.frontend_sdk_scopes.includes(s.value) ) as scope (scope.value)} s !== scope.value) // Keep the curated order so the consent banner and the stored - // consent compare stably across deploys. + // consent compare stably across deploys. A leftover implicit scope is + // not in the list, so it drops here rather than keep minting a token + // for an app whose toggles all read off. const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => next.includes(s) ) @@ -481,7 +483,7 @@
    {/each}
    - {#if policy.frontend_sdk_scopes?.length} + {#if sdkScopesToConfirm(policy.frontend_sdk_scopes ?? []).length}
    A sandboxed app calls the API cross-origin, which older windmill-client diff --git a/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte b/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte index 28940eb02a..babe0b8c85 100644 --- a/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/common' import Toggle from '$lib/components/Toggle.svelte' import { ShieldCheck } from 'lucide-svelte' - import { sdkScopeDescription, sdkScopeLabel } from './sdkScopes' + import { sdkScopeDescription, sdkScopeLabel, sdkScopesToConfirm } from './sdkScopes' let { scopes, @@ -35,7 +35,7 @@ The app's code will be able to call the Windmill API on your behalf, restricted to:

      - {#each scopes as scope (scope)} + {#each sdkScopesToConfirm(scopes) as scope (scope)}
    • {sdkScopeLabel(scope)}{#if sdkScopeDescription(scope)} { beforeEach(() => localStorage.clear()) it('only covers scopes the viewer actually approved', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(true) // The app added a scope after the viewer consented: it must ask again // rather than silently minting a broader token. - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read', 'jobs:run'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read', 'jobs:run'])).toBe(false) }) it('does not leak one viewer or app to another', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['users:read'])).toBe(false) - expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['users:read'])).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['jobs:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['jobs:read'])).toBe(false) }) it('keeps the editor preview and the deployed app apart, and moves preview consent', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'], true)).toBe(false) - storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'])).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'], true)).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'], true) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'])).toBe(false) movePreviewSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', 'u/a/real') - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['users:read'], true)).toBe(true) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true)).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['jobs:read'], true)).toBe(true) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'], true)).toBe(false) + }) + + it('never asks for the identity scope every token carries', () => { + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read', 'users:read'])).toBe(true) }) it('treats unreadable storage as no consent', () => { localStorage.setItem('wm_sdk_consent:a@w.dev:ws:u/a/app', 'not json') - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(false) + // Nothing to ask for: storage must not turn that into an empty prompt. + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) }) }) diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.ts b/frontend/src/lib/components/raw_apps/sdkScopes.ts index 238f1b7278..a4600979ba 100644 --- a/frontend/src/lib/components/raw_apps/sdkScopes.ts +++ b/frontend/src/lib/components/raw_apps/sdkScopes.ts @@ -1,7 +1,12 @@ // Frontend-SDK permissions for raw apps: the curated scopes an app author may // declare in `policy.frontend_sdk_scopes` (mirrors FRONTEND_SDK_ALLOWED_SCOPES -// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side -// consent persistence for the permission banner. +// in the backend `apps.rs` — both lists must stay in sync, except for the +// implicit scope below), plus the viewer-side consent persistence for the +// permission banner. + +/** Never toggled nor asked for: every SDK token reaches whoami without it, and whoami + * returns what the bundle already gets in `window.ctx`. */ +export const IMPLICIT_SDK_SCOPE = 'users:read' export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [ { @@ -14,11 +19,6 @@ export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: s label: 'Read jobs and results', description: 'Read jobs and their results, and list the runs the viewer can see' }, - { - value: 'users:read', - label: 'Read your identity', - description: 'Call whoami (Read the viewer username, email, groups, is_super_admin...)' - }, { value: 'resources:read', label: 'Read resources', @@ -59,10 +59,18 @@ function sdkConsentKey(viewer: string, workspace: string, path: string, preview: return `wm_sdk_consent:${preview ? 'p:' : ''}${viewer}:${workspace}:${path}` } +/** The declared scopes the viewer is asked to approve. */ +export function sdkScopesToConfirm(scopes: string[]): string[] { + return scopes.filter((s) => s !== IMPLICIT_SDK_SCOPE) +} + /** True when `approved` (a consent the viewer gave) covers every scope the app now * declares. Fewer scopes need no new consent; one more does. */ export function sdkConsentCovers(approved: unknown, scopes: string[]): boolean { - return Array.isArray(approved) && scopes.every((s) => approved.includes(s)) + const needed = sdkScopesToConfirm(scopes) + return ( + needed.length === 0 || (Array.isArray(approved) && needed.every((s) => approved.includes(s))) + ) } /** True when a previously stored "do not ask again" consent covers every @@ -80,7 +88,7 @@ export function hasStoredSdkConsent( ) return sdkConsentCovers(stored, scopes) } catch (_) { - return false + return sdkConsentCovers(null, scopes) } } diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index 2c12e514b9..b3b70cfaa2 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -608,6 +608,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "include_draft_only": { "type": "boolean", "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n" + }, + "starred_only": { + "type": "boolean", + "description": "Only the `ai_agent` resources the caller starred" } }, "required": [] @@ -1343,7 +1347,7 @@ export const mcpEndpointTools: EndpointTool[] = [ "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } } @@ -1460,7 +1464,7 @@ export const mcpEndpointTools: EndpointTool[] = [ "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } }, diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 9aee298999..69565dd9d4 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1148,7 +1148,7 @@ When a deploy widens who may open the app, tell the user in plain words. ## Sandbox isolation and frontend API access -New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are \`jobs:run\`, \`jobs:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in \`window.ctx\`, and whoami works with any of them. Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry \`Origin: null\`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or \`localStorage\`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. You cannot turn isolation off; the user does it in the app's deploy settings. diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 7a5667c106..df5e7ece6a 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -483,14 +483,14 @@ An app's custom URL is not in `raw_app.yaml`: each workspace has its own file ne ## Sandbox isolation and frontend API access -New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them. Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. In `raw_app.yaml`: ```yaml sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] ``` A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise. diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 1323e4228a..dc70d0b94d 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -251,7 +251,7 @@ An app's custom URL is not in `raw_app.yaml`: each workspace has its own file ne ## Sandbox isolation and frontend API access -New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +New apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them. Inside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app. @@ -262,7 +262,7 @@ In `raw_app.yaml`: ```yaml sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] ``` A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise.