Merge remote-tracking branch 'origin/main' into datatable-connect-as-viewer-roles

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
Ruben Fiszel
2026-10-12 21:00:00 +02:00
90 changed files with 2728 additions and 951 deletions
+1 -1
View File
@@ -295,4 +295,4 @@ jobs:
# never reaches it. Pin the target dir (matching the cache step above) so
# its own tests run off this compile rather than a second bundled build.
(cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal)
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp,http_trigger --all -- --nocapture --test-threads=10
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp,http_trigger,openidconnect --all -- --nocapture --test-threads=10
@@ -1,38 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n runnable_path,\n permissioned_as_email,\n parent_job\n FROM v2_job\n WHERE workspace_id = 'test-workspace'\n AND permissioned_as_email = 'success_handler@windmill.dev'\n ORDER BY created_at DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "parent_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
true,
false,
true
]
},
"hash": "71de038f332593e2177f33284854b6c0bfe9bb4999f2c4e9082a4c5f3b341496"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO global_settings (name, value) VALUES ($1, 'true'::jsonb) ON CONFLICT (name) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar"
]
},
"nullable": []
},
"hash": "8645e610aa35088376d0919132459bce3ad17df617855e5fcf838f6349e68c97"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM global_settings WHERE name = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "966b065bd6bbbff9e0ca15bee504f9cfe5e3a75427767de41e5b85c797f7d58a"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO global_settings (name, value) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, updated_at = now() WHERE global_settings.value IS DISTINCT FROM EXCLUDED.value",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb"
]
},
"nullable": []
},
"hash": "f633095ebe2fdabd116d687c7f8cfb8cb76159d8db20a42e0fce71146f9b8aaa"
}
+6
View File
@@ -15536,6 +15536,7 @@ dependencies = [
"windmill-api-scripts",
"windmill-api-settings",
"windmill-api-users",
"windmill-audit",
"windmill-autoscaling",
"windmill-common",
"windmill-dep-map",
@@ -15658,12 +15659,14 @@ dependencies = [
"native-tls",
"openidconnect",
"openssl",
"p256",
"pin-project",
"postgres-native-tls 0.5.3",
"prometheus",
"quick-xml",
"quick_cache",
"rand 0.9.5",
"rcgen",
"regex",
"reqwest 0.13.5",
"rsa",
@@ -15684,6 +15687,7 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-postgres",
"tokio-rustls 0.26.6",
"tokio-stream",
"tokio-util",
"tower 0.5.3",
@@ -16346,6 +16350,7 @@ dependencies = [
"opentelemetry-otlp 0.30.0",
"opentelemetry-semantic-conventions 0.30.0",
"opentelemetry_sdk 0.30.0",
"p256",
"pep440_rs",
"phf 0.11.3",
"pin-project-lite",
@@ -16630,6 +16635,7 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
"windmill-audit",
"windmill-common",
]
+3 -1
View File
@@ -156,7 +156,7 @@ bedrock = ["windmill-ai/bedrock", "windmill-api/bedrock", "windmill-worker/bedro
mqtt_trigger = ["windmill-api/mqtt_trigger"]
amqp_trigger = ["windmill-api/amqp_trigger"]
native_trigger = ["windmill-api/native_trigger"]
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "openidconnect"]
gcp_trigger = ["windmill-api/gcp_trigger"]
azure_trigger = ["windmill-api/azure_trigger"]
smtp = ["windmill-api/smtp", "windmill-common/smtp", "windmill-queue/smtp"]
@@ -281,6 +281,7 @@ windmill-api = { workspace = true, default-features = false }
windmill-api-agent-workers = { workspace = true, optional = true }
windmill-api-scripts.workspace = true
windmill-api-settings.workspace = true
windmill-audit.workspace = true
windmill-worker.workspace = true
windmill-indexer = { workspace = true, optional = true }
windmill-autoscaling = { workspace = true, optional = true }
@@ -594,6 +595,7 @@ const_format = { version = "0.2.35", features = ["rust_1_64", "rust_1_51"] }
const-str = "0.5"
constant_time_eq = "0.3.1"
rsa = "^0"
p256 = "0.13"
spki = { version = "0.7", features = ["pem"] }
pkcs1 = "0.7"
aes-gcm = "0.10.3"
+1 -1
View File
@@ -1 +1 @@
06083a1d8060b2eb92df954c0aef4a9e7d857e28
6dedb4687f591a88aba1503d55a001fcb0ab128a
+44 -1
View File
@@ -235,7 +235,7 @@ lazy_static::lazy_static! {
// -- $name (type) = default
static ref RE_ARG_DUCKDB: Regex = Regex::new(r#"(?m)^-- \$(\w+) \(([A-Za-z0-9_\[\]]+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
static ref RE_ARG_SNOWFLAKE: Regex = Regex::new(r#"(?m)^-- \? (\w+) \((\w+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
static ref RE_ARG_SNOWFLAKE: Regex = Regex::new(r#"(?m)^-- \? (\w+) \((\w+(?:\[\])?)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
static ref RE_ARG_MSSQL: Regex = Regex::new(r#"(?m)^-- @(?:P|p)\d+ (\w+) \((\w+)\)(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
@@ -254,6 +254,12 @@ fn parsed_default(parsed_typ: &Typ, default: String) -> Option<serde_json::Value
Typ::Str(_) if default.len() >= 2 && default.starts_with("'") && default.ends_with("'") => {
Some(json!(&default[1..default.len() - 1]))
}
Typ::List(_) => Some(
serde_json::from_str::<serde_json::Value>(&default)
.ok()
.filter(|v| v.is_array())
.unwrap_or_else(|| json!(default)),
),
_ => Some(json!(default)),
}
}
@@ -1039,7 +1045,11 @@ pub fn parse_duckdb_typ(typ: &str) -> Typ {
}
pub fn parse_snowflake_typ(typ: &str) -> Typ {
if let Some(base_typ) = typ.strip_suffix("[]") {
return Typ::List(Box::new(parse_snowflake_typ(base_typ)));
}
match typ {
"array" => Typ::List(Box::new(Typ::Unknown)),
"varchar" | "text" | "string" | "char" | "character" => Typ::Str(None),
"binary" | "varbinary" => Typ::Bytes,
"date" | "time" | "datetime" | "timestamp" | "timestamp_ntz" | "timestamp_ltz"
@@ -1591,6 +1601,39 @@ SELECT ?;
Ok(())
}
#[test]
fn test_parse_snowflake_array_types() -> anyhow::Result<()> {
let code = r#"
-- ? groups (varchar[])
-- ? ids (int[]) = [1, 2]
-- ? anything (ARRAY)
SELECT 1;
"#;
let args = parse_snowflake_sig(code)?.args;
assert_eq!(args[1].default, Some(json!([1, 2])));
let got = args
.iter()
.map(|a| (a.name.as_str(), a.otyp.as_deref(), &a.typ))
.collect::<Vec<_>>();
assert_eq!(
got,
vec![
(
"groups",
Some("varchar[]"),
&Typ::List(Box::new(Typ::Str(None)))
),
("ids", Some("int[]"), &Typ::List(Box::new(Typ::Int))),
(
"anything",
Some("array"),
&Typ::List(Box::new(Typ::Unknown))
),
]
);
Ok(())
}
#[test]
fn test_parse_mssql_sig() -> anyhow::Result<()> {
let code = r#"
+75 -16
View File
@@ -437,6 +437,8 @@ fn print_help() {
println!(" cache-rt Pre-cache hub resource types");
println!(" sync-config <file> Sync instance config from a YAML file to the database");
println!(" operator Run the Kubernetes operator (watches a ConfigMap)");
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
println!(" oidc-signer <conformance|test-signer> (ee) Check an external OIDC signer, or run a file-backed one for tests");
println!();
println!("Environment variables (name = default):");
println!(" DATABASE_URL = <required> The Postgres database url.");
@@ -471,6 +473,38 @@ fn print_help() {
println!("- At startup, Windmill logs currently set configuration keys for visibility.");
}
async fn otel_environment(conn: &Connection) -> String {
if let Ok(environment) = std::env::var("OTEL_ENVIRONMENT") {
environment
} else {
load_base_url(conn)
.await
.unwrap_or_else(|_| "local".to_string())
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(".")
.next()
.unwrap_or_else(|| "local")
.to_string()
}
}
/// Tracing for a subcommand that writes instance config. It never runs the server startup
/// that loads the OTel settings, and its audit events are exported under them.
///
/// The subscriber it installs depends on those settings, so `early_logs` is what prints
/// the events of connecting and loading them; it gives way once the real one is in.
async fn init_declarative_writer_tracing(
db: &windmill_common::DB,
service: &str,
early_logs: tracing::subscriber::DefaultGuard,
) {
load_otel(db).await;
let environment = otel_environment(&Connection::Sql(db.clone())).await;
drop(early_logs);
windmill_common::tracing_init::initialize_subcommand_tracing(service, &HOSTNAME, &environment);
}
async fn windmill_main() -> anyhow::Result<()> {
let (killpill_tx, mut killpill_rx) = KillpillSender::new(2);
let mut monitor_killpill_rx = killpill_tx.subscribe();
@@ -574,7 +608,6 @@ async fn windmill_main() -> anyhow::Result<()> {
return Ok(());
}
"sync-config" => {
tracing_subscriber::fmt::init();
let path = std::env::args().nth(2).unwrap_or_else(|| {
eprintln!("Usage: windmill sync-config <file>");
std::process::exit(1);
@@ -588,15 +621,40 @@ async fn windmill_main() -> anyhow::Result<()> {
windmill_common::instance_config::resolve_env_refs(&mut config.global_settings)
.map_err(|var| anyhow::anyhow!("environment variable '{var}' not found"))?;
let early_logs = tracing::subscriber::set_default(tracing_subscriber::fmt().finish());
tracing::info!("Connecting to database...");
let db = crate::db_connect::initial_connection().await?;
config.sync_to_db(&db).await?;
init_declarative_writer_tracing(&db, "windmill-sync-config", early_logs).await;
let author = windmill_audit::instance_config::declarative_sync_author();
let synced = config
.sync_to_db(
&db,
&windmill_audit::instance_config::InstanceConfigAuditor {
db: &db,
author: &author,
source: Some(
windmill_audit::instance_config::DeclarativeSource::SyncConfig,
),
},
)
.await;
// Also on failure: the settings written before it are already audited, and the
// process exits before the exporter's next scheduled batch.
windmill_common::otel_oss::flush_otel_logs().await;
synced?;
tracing::info!("Synced instance config from {path}");
return Ok(());
}
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
"oidc-signer" => {
tracing_subscriber::fmt::init();
windmill_api::oidc_signer_tools_ee::run_cli(std::env::args().skip(2).collect()).await?;
return Ok(());
}
#[cfg(feature = "operator")]
"operator" => {
tracing_subscriber::fmt::init();
let early_logs = tracing::subscriber::set_default(tracing_subscriber::fmt().finish());
tracing::info!("Starting Windmill Kubernetes operator...");
tracing::info!("Connecting to database...");
@@ -609,6 +667,7 @@ async fn windmill_main() -> anyhow::Result<()> {
operator_killpill_rx,
)
.await?;
init_declarative_writer_tracing(&db, "windmill-operator", early_logs).await;
#[cfg(all(feature = "enterprise", feature = "private"))]
tokio::spawn(async move {
@@ -735,19 +794,7 @@ async fn windmill_main() -> anyhow::Result<()> {
(Connection::Sql(db), None, None)
};
let environment = if let Ok(environment) = std::env::var("OTEL_ENVIRONMENT") {
environment
} else {
load_base_url(&conn)
.await
.unwrap_or_else(|_| "local".to_string())
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(".")
.next()
.unwrap_or_else(|| "local")
.to_string()
};
let environment = otel_environment(&conn).await;
let _guard = windmill_common::tracing_init::initialize_tracing(&hostname, &mode, &environment);
@@ -1003,6 +1050,15 @@ Windmill Community Edition {GIT_VERSION}
// empty script cache, refilled from the database.
windmill_common::cache::script::clear();
// Before the first load: it can sign a token for instance object storage.
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
windmill_common::oidc_ee::init_oidc_signing(
&conn,
server_mode,
killpill_rx.resubscribe(),
)
.await?;
initial_load(
&conn,
killpill_tx.clone(),
@@ -2095,6 +2151,9 @@ async fn process_notify_event(
SYNC_HUB_RESOURCE_TYPES_DAILY_SETTING => {
// Read by the daily resource type sync each time it runs
}
"oidc_external_signer" => {
// Written by servers that sign with an external signer, read when signing
}
"plain_emails_telemetry" => {
let enabled = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'plain_emails_telemetry'"
+88
View File
@@ -0,0 +1,88 @@
//! A context input a raw app runnable declares (`fields.<arg> = { type: "ctx", .. }`) is
//! filled by the server from the viewer's session. Whatever the request sends under that
//! name is discarded: a runnable filters rows on it, so a viewer who could set it would
//! read someone else's.
//!
//! Users from the `base` fixture:
//! test-user (admin, token SECRET_TOKEN)
//! test-user-2 (developer, token SECRET_TOKEN_2)
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
const VIEWER_TOKEN: &str = "SECRET_TOKEN_2";
const APP_PATH: &str = "u/test-user/ctx_app";
const CONTENT: &str = "export function main(who: string) { return who }";
// sha256 of CONTENT, the key an inline runnable is pinned under.
const CONTENT_SHA: &str = "6e198199621d57e4fed834510cb0c2692274aa4cbe976e1704fab2806be9d744";
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
#[sqlx::test(fixtures("base"))]
async fn a_declared_ctx_input_ignores_the_value_the_viewer_sends(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
let client = reqwest::Client::new();
let resp = authed(client.post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": APP_PATH,
"summary": "ctx inputs",
"value": {
"runnables": {
"whoami": {
"type": "inline",
"inlineScript": { "language": "bun", "content": CONTENT },
"fields": { "who": { "type": "ctx", "ctx": "username" } }
}
}
},
// The policy says nothing of context inputs: the server reads the
// declaration from the deployed runnable.
"policy": {
"execution_mode": "anonymous",
"triggerables_v2": {
format!("whoami:rawscript/{CONTENT_SHA}"): {
"static_inputs": {},
"one_of_inputs": {}
}
}
}
}))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
// Both spellings a viewer could send: a forged literal, and no argument at all.
for args in [json!({ "who": "test-user" }), json!({})] {
let resp = authed(
client.post(format!("{ws}/apps_u/execute_component/{APP_PATH}")),
VIEWER_TOKEN,
)
.json(&json!({
"component": "whoami",
"args": args,
"raw_code": { "language": "bun", "content": CONTENT }
}))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
let job_id: uuid::Uuid = resp.text().await?.parse()?;
let queued: serde_json::Value = sqlx::query_scalar("SELECT args FROM v2_job WHERE id = $1")
.bind(job_id)
.fetch_one(&db)
.await?;
assert_eq!(queued["who"], json!("test-user-2"), "sent {args}");
}
Ok(())
}
+139 -27
View File
@@ -237,7 +237,7 @@ async fn test_diff_global_settings_empty_string_routes_to_delete(db: Pool<Postgr
"empty-string value must route to deletes"
);
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert!(
get_global_setting(&db, "npmrc").await.is_none(),
"npmrc row must be deleted after apply"
@@ -319,7 +319,7 @@ async fn test_diff_global_settings_rejects_worker_configs_upsert(db: Pool<Postgr
"worker_configs must never be written as a global setting"
);
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert!(
get_global_setting(&db, "worker_configs").await.is_none(),
"worker_configs row must not exist in global_settings after apply"
@@ -444,7 +444,7 @@ async fn test_apply_settings_diff_upserts_only(db: Pool<Postgres>) {
..Default::default()
};
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert_eq!(
get_global_setting(&db, "key_a").await,
@@ -468,7 +468,7 @@ async fn test_apply_settings_diff_deletes_only(db: Pool<Postgres>) {
..Default::default()
};
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert!(get_global_setting(&db, "to_delete_1").await.is_none());
assert!(get_global_setting(&db, "to_delete_2").await.is_none());
@@ -489,7 +489,7 @@ async fn test_apply_settings_diff_upserts_and_deletes(db: Pool<Postgres>) {
..Default::default()
};
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert!(get_global_setting(&db, "old_key").await.is_none());
assert_eq!(
@@ -503,7 +503,7 @@ async fn test_apply_settings_diff_empty_noop(db: Pool<Postgres>) {
insert_global_setting(&db, "preexisting", serde_json::json!("value")).await;
let diff = SettingsDiff::default();
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert_eq!(
get_global_setting(&db, "preexisting").await,
@@ -525,7 +525,7 @@ async fn test_apply_settings_diff_upsert_overwrites(db: Pool<Postgres>) {
..Default::default()
};
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert_eq!(
get_global_setting(&db, "overwrite_me").await,
@@ -553,7 +553,7 @@ async fn test_apply_settings_diff_complex_json(db: Pool<Postgres>) {
..Default::default()
};
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
let stored = get_global_setting(&db, "complex_setting").await.unwrap();
assert_eq!(stored, complex_value);
@@ -568,7 +568,7 @@ async fn test_apply_settings_diff_delete_nonexistent_is_noop(db: Pool<Postgres>)
};
// Should not error
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
}
// ========================================================================
@@ -589,7 +589,7 @@ async fn test_apply_configs_diff_upserts_with_prefix(db: Pool<Postgres>) {
deletes: vec![],
};
apply_configs_diff(&db, &diff).await.unwrap();
apply_configs_diff(&db, &diff, &()).await.unwrap();
let stored = get_config(&db, "worker__mygroup").await.unwrap();
assert_eq!(stored["init_bash"], "echo hi");
@@ -601,7 +601,7 @@ async fn test_apply_configs_diff_deletes_with_prefix(db: Pool<Postgres>) {
let diff = ConfigsDiff { upserts: BTreeMap::new(), deletes: vec!["to_remove".to_string()] };
apply_configs_diff(&db, &diff).await.unwrap();
apply_configs_diff(&db, &diff, &()).await.unwrap();
assert!(get_config(&db, "worker__to_remove").await.is_none());
}
@@ -619,7 +619,7 @@ async fn test_apply_configs_diff_upsert_overwrites(db: Pool<Postgres>) {
deletes: vec![],
};
apply_configs_diff(&db, &diff).await.unwrap();
apply_configs_diff(&db, &diff, &()).await.unwrap();
let stored = get_config(&db, "worker__grp").await.unwrap();
assert_eq!(stored, serde_json::json!({"new": true}));
@@ -630,7 +630,7 @@ async fn test_apply_configs_diff_empty_noop(db: Pool<Postgres>) {
insert_config(&db, "worker__keep", serde_json::json!({"keep": true})).await;
let diff = ConfigsDiff::default();
apply_configs_diff(&db, &diff).await.unwrap();
apply_configs_diff(&db, &diff, &()).await.unwrap();
assert!(get_config(&db, "worker__keep").await.is_some());
}
@@ -641,7 +641,7 @@ async fn test_apply_configs_diff_does_not_touch_non_worker_configs(db: Pool<Post
let diff = ConfigsDiff { upserts: BTreeMap::new(), deletes: vec!["server_config".to_string()] };
apply_configs_diff(&db, &diff).await.unwrap();
apply_configs_diff(&db, &diff, &()).await.unwrap();
// The delete targets "worker__server_config", not "server_config"
assert!(
@@ -692,7 +692,7 @@ async fn test_roundtrip_write_read_modify_apply(db: Pool<Postgres>) {
assert!(diff.upserts.contains_key("expose_metrics"));
assert!(diff.deletes.is_empty());
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
// Step 5: Read back and verify
let config2 = InstanceConfig::from_db(&db).await.unwrap();
@@ -729,7 +729,7 @@ async fn test_roundtrip_replace_mode_deletes(db: Pool<Postgres>) {
assert!(diff.deletes.contains(&"retention_period_secs".to_string()));
assert!(diff.deletes.contains(&"expose_metrics".to_string()));
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert!(get_global_setting(&db, "retention_period_secs")
.await
@@ -782,7 +782,7 @@ async fn test_roundtrip_worker_configs(db: Pool<Postgres>) {
assert!(diff.upserts.contains_key("gpu")); // new
assert_eq!(diff.deletes, vec!["legacy".to_string()]);
apply_configs_diff(&db, &diff).await.unwrap();
apply_configs_diff(&db, &diff, &()).await.unwrap();
// Verify
let config2 = InstanceConfig::from_db(&db).await.unwrap();
@@ -820,7 +820,7 @@ async fn test_roundtrip_to_settings_map_from_db_consistency(db: Pool<Postgres>)
let diff =
SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() };
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
let config = InstanceConfig::from_db(&db).await.unwrap();
assert_eq!(config.global_settings.base_url, original.base_url);
@@ -868,8 +868,8 @@ async fn test_idempotent_apply(db: Pool<Postgres>) {
};
// Apply twice
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
assert_eq!(
get_global_setting(&db, "idem_key").await,
@@ -1069,7 +1069,7 @@ async fn test_diff_apply_only_touches_changed_rows(db: Pool<Postgres>) {
assert!(!diff.upserts.contains_key("unchanged_1"));
assert!(!diff.upserts.contains_key("unchanged_2"));
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
// Verify all 4 settings present
assert_eq!(count_global_settings(&db).await, 4);
@@ -1148,7 +1148,7 @@ async fn test_replace_mode_protects_settings_in_integration(db: Pool<Postgres>)
// But normal_setting should be deleted
assert!(diff.deletes.contains(&"normal_setting".to_string()));
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
// Verify protected settings survived
assert!(get_global_setting(&db, "ducklake_settings").await.is_some());
@@ -1196,7 +1196,7 @@ async fn test_jwt_secret_roundtrip_as_string_or_secret_ref(db: Pool<Postgres>) {
let diff =
SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() };
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
// Read back from DB — jwt_secret should survive the roundtrip
let config = InstanceConfig::from_db(&db).await.unwrap();
@@ -1239,7 +1239,7 @@ async fn test_rsa_keys_roundtrip_via_extra(db: Pool<Postgres>) {
let map = settings.to_settings_map();
let diff =
SettingsDiff { upserts: map.into_iter().collect(), deletes: vec![], ..Default::default() };
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
// Read back from DB
let config = InstanceConfig::from_db(&db).await.unwrap();
@@ -1296,7 +1296,7 @@ async fn test_replace_mode_protects_jwt_secret_and_rsa_keys(db: Pool<Postgres>)
"normal_setting should be deleted"
);
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
// Both protected settings survive
assert_eq!(
@@ -1371,7 +1371,7 @@ async fn test_alert_config_in_global_settings_roundtrip(db: Pool<Postgres>) {
"alert_config change should be detected in diff"
);
apply_settings_diff(&db, &diff).await.unwrap();
apply_settings_diff(&db, &diff, &()).await.unwrap();
// Re-read and verify the update
let config2 = InstanceConfig::from_db(&db).await.unwrap();
@@ -1455,6 +1455,7 @@ async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool<Postgres
&db,
&BTreeMap::new(),
&desired,
&(),
)
.await
.expect_err("an invalid webhook base url must fail the sync");
@@ -1474,6 +1475,7 @@ async fn declarative_sync_rejects_an_unusable_webhook_base_url(db: Pool<Postgres
&db,
&BTreeMap::new(),
&wrong_type,
&(),
)
.await
.expect_err("a non-string webhook base url must fail the sync");
@@ -1511,6 +1513,7 @@ async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool<Postgres>
&db,
&BTreeMap::new(),
&desired,
&(),
)
.await
.expect_err("a javascript: banner link must fail the sync");
@@ -1545,6 +1548,7 @@ async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool<Post
&db,
&BTreeMap::new(),
&desired,
&(),
)
.await
.expect_err("a ceiling the token routes cannot read must fail the sync");
@@ -1578,6 +1582,7 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<P
&db,
&BTreeMap::new(),
&desired,
&(),
)
.await
.expect_err(&format!("{bad} must fail the sync"));
@@ -1604,6 +1609,7 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<P
&db,
&BTreeMap::new(),
&desired,
&(),
)
.await
.expect("a valid origin list must sync");
@@ -1635,7 +1641,10 @@ async fn declarative_sync_keeps_the_external_cluster_while_it_holds_databases(db
let (db, current) = (db.clone(), current.clone());
async move {
windmill_common::instance_config::sync_global_settings_declarative(
&db, &current, &desired,
&db,
&current,
&desired,
&(),
)
.await
}
@@ -1670,6 +1679,7 @@ async fn declarative_sync_rejects_a_non_hex_accent_color(db: Pool<Postgres>) {
&db,
&BTreeMap::new(),
&desired,
&(),
)
.await
.expect_err("a non-hex accent color must fail the sync");
@@ -1681,7 +1691,109 @@ async fn declarative_sync_rejects_a_non_hex_accent_color(db: Pool<Postgres>) {
&db,
&BTreeMap::new(),
&desired,
&(),
)
.await
.expect("a hex accent color must sync");
}
/// A declarative sync has no user request behind it, so its observer is the only thing
/// that audits it: one entry per setting or worker config it actually writes, values
/// recorded under the API path's rule, and none for what it leaves as is.
#[cfg(all(feature = "enterprise", feature = "private"))]
#[sqlx::test(fixtures("base"))]
async fn test_declarative_sync_audits_only_what_it_writes(db: Pool<Postgres>) {
use windmill_audit::instance_config::{
declarative_sync_author, DeclarativeSource, InstanceConfigAuditor,
};
insert_global_setting(&db, "retention_period_secs", serde_json::json!(100)).await;
insert_global_setting(&db, "expose_metrics", serde_json::json!(true)).await;
insert_global_setting(&db, "scim_token", serde_json::json!("old-scim-secret")).await;
insert_global_setting(
&db,
"npm_config_registry",
serde_json::json!("https://u:p@npm"),
)
.await;
insert_config(
&db,
"worker__kept",
serde_json::json!({"worker_tags": ["a"]}),
)
.await;
insert_config(
&db,
"worker__dropped",
serde_json::json!({"worker_tags": ["b"]}),
)
.await;
let config: windmill_common::instance_config::InstanceConfig =
serde_json::from_value(serde_json::json!({
"global_settings": {
"retention_period_secs": 200,
"expose_metrics": true,
"scim_token": "new-scim-secret",
},
"worker_configs": {
"kept": {"worker_tags": ["a"]},
"added": {"init_bash": "export TOKEN=init-secret"},
},
}))
.unwrap();
let author = declarative_sync_author();
config
.sync_to_db(
&db,
&InstanceConfigAuditor {
db: &db,
author: &author,
source: Some(DeclarativeSource::SyncConfig),
},
)
.await
.unwrap();
let entries: Vec<(String, String, serde_json::Value)> = sqlx::query_as(
"SELECT operation, resource, parameters FROM audit_partitioned \
WHERE workspace_id = 'global' AND username = $1 AND resource = ANY($2) \
ORDER BY resource",
)
.bind(&author.username)
.bind(vec![
"retention_period_secs",
"expose_metrics",
"scim_token",
"npm_config_registry",
"worker__kept",
"worker__dropped",
"worker__added",
])
.fetch_all(&db)
.await
.unwrap();
let source = serde_json::json!({"source": "sync-config"});
let entry = |operation: &str, resource: &str, parameters: &serde_json::Value| {
(
operation.to_string(),
resource.to_string(),
parameters.clone(),
)
};
assert_eq!(
entries,
vec![
entry("global_settings.delete", "npm_config_registry", &source),
entry(
"global_settings.update",
"retention_period_secs",
&serde_json::json!({"source": "sync-config", "value": "200"}),
),
entry("global_settings.update", "scim_token", &source),
entry("worker_config.update", "worker__added", &source),
entry("worker_config.delete", "worker__dropped", &source),
]
);
}
+110
View File
@@ -0,0 +1,110 @@
//! An MCP resource header whose value is a `$var:` reference reaches the MCP
//! server as the variable's value, and only for a caller allowed to read it.
#![cfg(feature = "mcp")]
use sqlx::{Pool, Postgres};
use tokio::io::AsyncReadExt;
use windmill_common::client::AuthedClient;
use windmill_test_utils::*;
use windmill_worker::{load_mcp_tools, McpResourceConfig};
const SECRET_VALUE: &str = "S3CRET-MCP-HEADER-VALUE";
/// A secret variable and an MCP resource at `url` sending it as a header.
async fn seed(db: &Pool<Postgres>, url: &str) -> anyhow::Result<()> {
let mc = windmill_common::variables::build_crypt(db, "test-workspace").await?;
sqlx::query(
"INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms)
VALUES ('test-workspace', 'u/test-user/mcp_header', $1, true, '', '{}')",
)
.bind(windmill_common::variables::encrypt(&mc, SECRET_VALUE))
.execute(db)
.await?;
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/mcp', $1, 'mcp', '{}', 'test-user')",
)
.bind(serde_json::json!({
"name": "mcp",
"url": url,
"headers": { "X-Api-Key": "$var:u/test-user/mcp_header", "X-Plain": "plain" }
}))
.execute(db)
.await?;
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_mcp_header_var_ref_is_resolved(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
std::env::set_var("ALLOW_PRIVATE_MCP_SERVER_URLS", "true");
// Not an MCP server: it captures the first request and hangs up, so loading
// fails at the connect step after the headers were sent.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let mcp_port = listener.local_addr()?.port();
let first_request = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await?;
let mut buf = vec![0u8; 16 * 1024];
let n = socket.read(&mut buf).await?;
anyhow::Ok(String::from_utf8_lossy(&buf[..n]).to_lowercase())
});
seed(&db, &format!("http://127.0.0.1:{mcp_port}/mcp")).await?;
let server = ApiServer::start(db.clone()).await?;
let client = AuthedClient::new(
format!("http://localhost:{}", server.addr.port()),
"test-workspace".to_string(),
"SECRET_TOKEN".to_string(),
None,
);
let config = vec![McpResourceConfig {
resource_path: "$res:u/test-user/mcp".to_string(),
include_tools: None,
exclude_tools: None,
}];
let _ = load_mcp_tools(&db, "test-workspace", config, &client).await;
let request = first_request.await??;
assert!(
request.contains(&format!("x-api-key: {}", SECRET_VALUE.to_lowercase())),
"the header must carry the variable's value, not the reference: {request}"
);
assert!(request.contains("x-plain: plain"), "{request}");
Ok(())
}
/// A token scoped to the resource alone must not have the API read the
/// variable a header points at.
#[sqlx::test(fixtures("base"))]
async fn test_mcp_header_var_ref_requires_variable_scope(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
seed(&db, "https://mcp.invalid.windmill.test").await?;
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
VALUES (encode(sha256('RES_ONLY_TOKEN'::bytea), 'hex'), 'RES_ONLY_T', 'RES_ONLY_TOKEN',
'test@windmill.dev', 'resources only', false, '{resources:read}')",
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let resp = reqwest::Client::new()
.get(format!(
"http://localhost:{}/api/w/test-workspace/resources/mcp_tools/u/test-user/mcp",
server.addr.port()
))
.header("Authorization", "Bearer RES_ONLY_TOKEN")
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{body}");
assert!(
body.contains("variables:read:u/test-user/mcp_header"),
"{body}"
);
Ok(())
}
+41 -56
View File
@@ -2,6 +2,36 @@ use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// Both job-running tests seed `f/test/success_handler` with a different hash, each in its own
/// database, while the deployed-script caches are process-global and keyed by workspace and
/// path. With them on, one test resolves the handler to the other's hash and the handler push
/// 404s whenever that hash's info is not cached yet.
#[cfg(all(feature = "enterprise", feature = "private"))]
fn disable_deployed_script_cache() {
windmill_common::DEPLOYED_SCRIPT_CACHE_DISABLED
.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// The handler job is pushed after the job it handles is reported complete.
#[cfg(all(feature = "enterprise", feature = "private"))]
async fn wait_for_success_handler_job(
db: &Pool<Postgres>,
) -> anyhow::Result<Option<(Option<String>, Option<uuid::Uuid>)>> {
for _ in 0..100 {
let job = sqlx::query_as::<_, (Option<String>, Option<uuid::Uuid>)>(
"SELECT runnable_path, parent_job FROM v2_job
WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'success_handler@windmill.dev'",
)
.fetch_optional(db)
.await?;
if job.is_some() {
return Ok(job);
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
Ok(None)
}
/// Test that the workspace success handler cache works correctly with 60s TTL
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
@@ -108,6 +138,7 @@ async fn test_success_handler_triggered_on_success(db: Pool<Postgres>) -> anyhow
use windmill_common::scripts::{ScriptHash, ScriptLang};
initialize_tracing().await;
disable_deployed_script_cache();
let server = ApiServer::start(db.clone()).await?;
@@ -191,49 +222,14 @@ export async function main(path: string, email: string, job_id: string, is_flow:
let main_job_id = completed_job.id;
// Wait a short time for the success handler job to be created
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Verify the success handler job was created (query by email since trigger is not set)
let success_handler_job = sqlx::query!(
r#"
SELECT
id,
runnable_path,
permissioned_as_email,
parent_job
FROM v2_job
WHERE workspace_id = 'test-workspace'
AND permissioned_as_email = 'success_handler@windmill.dev'
ORDER BY created_at DESC
LIMIT 1
"#
)
.fetch_optional(&db)
.await?;
assert!(
success_handler_job.is_some(),
"Success handler job should have been created"
);
let handler_job = success_handler_job.unwrap();
// Verify the success handler job has correct parameters
let handler_job = wait_for_success_handler_job(&db).await?;
assert_eq!(
handler_job.runnable_path.as_deref(),
Some("f/test/success_handler"),
"Success handler should run the configured script"
);
assert_eq!(
handler_job.permissioned_as_email.as_str(),
"success_handler@windmill.dev",
"Success handler should run as success_handler user"
);
assert_eq!(
handler_job.parent_job,
Some(main_job_id),
"Success handler should have main job as parent"
handler_job,
Some((
Some("f/test/success_handler".to_string()),
Some(main_job_id)
)),
"Success handler should run the configured script with the main job as parent"
);
// Note: root_job may be None when it equals parent_job (optimization in push function)
@@ -250,6 +246,7 @@ async fn test_success_handler_triggered_on_hub_script_success(
use windmill_common::jobs::JobPayload;
initialize_tracing().await;
disable_deployed_script_cache();
let server = ApiServer::start(db.clone()).await?;
@@ -285,21 +282,9 @@ async fn test_success_handler_triggered_on_hub_script_success(
.await;
assert!(completed_job.success, "Hub script should have succeeded");
let mut handler_parent = None;
for _ in 0..50 {
handler_parent = sqlx::query_scalar::<_, Option<uuid::Uuid>>(
"SELECT parent_job FROM v2_job
WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'success_handler@windmill.dev'",
)
.fetch_optional(&db)
.await?;
if handler_parent.is_some() {
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
let handler_job = wait_for_success_handler_job(&db).await?;
assert_eq!(
handler_parent,
handler_job.map(|(_, parent)| parent),
Some(Some(completed_job.id)),
"Workspace success handler should handle the successful hub script"
);
+34 -11
View File
@@ -1,3 +1,4 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::collections::HashMap;
@@ -533,7 +534,7 @@ pub struct OpenAPISchema {
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<OpenAPISchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<HashMap<String, Box<OpenAPISchema>>>,
pub properties: Option<IndexMap<String, Box<OpenAPISchema>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "oneOf")]
@@ -560,9 +561,9 @@ pub struct OpenAPISchema {
#[serde(skip_serializing_if = "Option::is_none", rename = "$ref")]
pub ref_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "$defs")]
pub defs: Option<HashMap<String, Box<OpenAPISchema>>>,
pub defs: Option<IndexMap<String, Box<OpenAPISchema>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub definitions: Option<HashMap<String, Box<OpenAPISchema>>>,
pub definitions: Option<IndexMap<String, Box<OpenAPISchema>>>,
// Schema composition
#[serde(skip_serializing_if = "Option::is_none", rename = "allOf")]
@@ -829,7 +830,7 @@ impl OpenAPISchema {
// Merge properties (other's properties are added if key doesn't exist)
if let Some(ref other_props) = other.properties {
let props = self.properties.get_or_insert_with(HashMap::new);
let props = self.properties.get_or_insert_with(IndexMap::new);
for (key, value) in other_props {
props.entry(key.clone()).or_insert_with(|| value.clone());
}
@@ -880,13 +881,13 @@ impl OpenAPISchema {
// Merge $defs and definitions
if let Some(ref other_defs) = other.defs {
let defs = self.defs.get_or_insert_with(HashMap::new);
let defs = self.defs.get_or_insert_with(IndexMap::new);
for (key, value) in other_defs {
defs.entry(key.clone()).or_insert_with(|| value.clone());
}
}
if let Some(ref other_definitions) = other.definitions {
let definitions = self.definitions.get_or_insert_with(HashMap::new);
let definitions = self.definitions.get_or_insert_with(IndexMap::new);
for (key, value) in other_definitions {
definitions
.entry(key.clone())
@@ -981,7 +982,29 @@ pub struct S3ObjectWithType {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// The schema is part of the prompt prefix the provider caches on, so its keys must
/// serialize in the same order on every request.
#[test]
fn strict_schema_keeps_the_defined_property_order() {
let keys = [
"zeta", "alpha", "mid", "beta", "omega", "gamma", "delta", "kappa",
];
let props = keys
.iter()
.map(|k| format!(r#""{k}":{{"type":"string"}}"#))
.collect::<Vec<_>>()
.join(",");
let mut schema: OpenAPISchema =
serde_json::from_str(&format!(r#"{{"type":"object","properties":{{{props}}}}}"#))
.unwrap();
schema.make_strict();
let value = serde_json::to_value(&schema).unwrap();
let serialized: Vec<&String> = value["properties"].as_object().unwrap().keys().collect();
assert_eq!(serialized, keys);
assert_eq!(value["required"], serde_json::json!(keys));
}
/// Whichever provider served the request, `input_tokens` has to end up meaning the
/// whole prompt. Leaving the Anthropic shape as reported under-states it by the
@@ -1248,7 +1271,7 @@ mod tests {
#[test]
fn test_make_strict_defs() {
let def_schema = object_schema(vec![("prop", string_schema())]);
let mut defs = HashMap::new();
let mut defs = IndexMap::new();
defs.insert("MyType".to_string(), Box::new(def_schema));
let mut schema = OpenAPISchema { defs: Some(defs), ..Default::default() };
@@ -1274,7 +1297,7 @@ mod tests {
#[test]
fn test_make_strict_definitions() {
let def_schema = object_schema(vec![("prop", string_schema())]);
let mut definitions = HashMap::new();
let mut definitions = IndexMap::new();
definitions.insert("MyType".to_string(), Box::new(def_schema));
let mut schema = OpenAPISchema { definitions: Some(definitions), ..Default::default() };
@@ -1528,7 +1551,7 @@ mod tests {
#[test]
fn test_flatten_all_of_preserves_defs() {
let def_schema = object_schema(vec![("field", string_schema())]);
let mut defs = HashMap::new();
let mut defs = IndexMap::new();
defs.insert("MyType".to_string(), Box::new(def_schema));
let schema_with_defs = OpenAPISchema { defs: Some(defs), ..Default::default() };
@@ -1642,7 +1665,7 @@ mod tests {
schema_url: Some("http://json-schema.org/draft-07/schema#".to_string()),
..Default::default()
};
let mut defs = HashMap::new();
let mut defs = IndexMap::new();
defs.insert("MyType".to_string(), Box::new(def_schema));
let mut schema = OpenAPISchema {
+2 -1
View File
@@ -1492,7 +1492,8 @@ async fn set_default_error_handler(
} else {
windmill_api_settings::delete_global_setting(&db, key.as_str()).await?;
}
windmill_api_settings::audit_setting_write(&db, &authed, &key, value.as_ref()).await?;
windmill_audit::instance_config::audit_setting_write(&db, &authed, &key, value.as_ref(), None)
.await?;
if payload.override_existing {
// The rewrite and its history rows go in one transaction: on separate
+18 -129
View File
@@ -29,7 +29,11 @@ mod log_cleanup;
mod storage_usage;
use windmill_api_auth::{require_devops_role, require_super_admin, ApiAuthed};
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_audit::{
audit_oss::audit_log,
instance_config::{audit_setting_write, InstanceConfigAuditor},
ActionKind,
};
use windmill_common::hub_resource_types::{self, SyncSource};
use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT;
use windmill_common::DB;
@@ -833,50 +837,7 @@ pub async fn set_global_setting(
require_super_admin(&db, &authed).await?;
let value = value.value.unwrap_or(serde_json::Value::Null);
set_global_setting_internal(&db, key.clone(), value.clone()).await?;
audit_setting_write(&db, &authed, &key, Some(&value)).await
}
/// A setting is recorded by name, with its value only when that is a boolean or a number.
/// Any other shape can carry a credential (a registry URL, a webhook, a header map), and no
/// list of secret-bearing settings stays complete as settings are added.
///
/// AUTHORIZATION: records a write, checks nothing. The caller must have established that
/// `authed` may change instance settings before performing the write this records.
pub async fn audit_setting_write(
db: &DB,
authed: &ApiAuthed,
key: &str,
value: Option<&serde_json::Value>,
) -> error::Result<()> {
let deleted = match value {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(s)) => s.trim().is_empty(),
Some(_) => false,
};
let recorded = match value {
Some(v @ (serde_json::Value::Bool(_) | serde_json::Value::Number(_))) => {
Some(v.to_string())
}
_ => None,
};
audit_log(
db,
authed,
if deleted {
"global_settings.delete"
} else {
"global_settings.update"
},
if deleted {
ActionKind::Delete
} else {
ActionKind::Update
},
"global",
Some(key),
recorded.as_deref().map(|v| [("value", v)].into()),
)
.await
audit_setting_write(&db, &authed, &key, Some(&value), None).await
}
async fn audit_instance_action(
@@ -1367,65 +1328,17 @@ async fn set_instance_config(
run_setting_pre_write_hook(&db, key, value).await?;
}
}
let auditor = InstanceConfigAuditor { db: &db, author: &authed, source: None };
// The external-instance write removes its own key from the diff.
let external = settings_diff
.upserts
.get(EXTERNAL_INSTANCE_PG_SETTING)
.cloned()
.map(Some)
.or_else(|| {
settings_diff
.deletes
.iter()
.any(|k| k == EXTERNAL_INSTANCE_PG_SETTING)
.then_some(None)
});
windmill_common::external_instance_pg::write_external_instance_pg_from_diff(
&db,
&mut settings_diff,
&auditor,
)
.await?;
if let Some(value) = external {
audit_setting_write(&db, &authed, EXTERNAL_INSTANCE_PG_SETTING, value.as_ref()).await?;
}
// Applied and audited one setting at a time: the writes are not transactional, so a
// batch that fails midway must not leave the settings it did write unrecorded.
let instance_config::SettingsDiff {
upserts,
deletes,
mut previous_values,
unchanged_count,
} = settings_diff;
let steps = upserts
.into_iter()
.map(|(k, v)| (k, Some(v)))
.chain(deletes.into_iter().map(|k| (k, None)))
.map(Some)
.chain(std::iter::once(None));
for step in steps {
let mut diff = instance_config::SettingsDiff::default();
match &step {
Some((key, value)) => {
if let Some(previous) = previous_values.remove(key) {
diff.previous_values.insert(key.clone(), previous);
}
match value {
Some(value) => {
diff.upserts.insert(key.clone(), value.clone());
}
None => diff.deletes.push(key.clone()),
}
}
None => diff.unchanged_count = unchanged_count,
}
instance_config::apply_settings_diff(&db, &diff)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
if let Some((key, value)) = &step {
audit_setting_write(&db, &authed, key, value.as_ref()).await?;
}
}
instance_config::apply_settings_diff(&db, &settings_diff, &auditor)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
if ai_config_changed {
bump_instance_ai_config_revision();
@@ -1455,37 +1368,13 @@ async fn set_instance_config(
.collect();
let configs_diff =
instance_config::diff_worker_configs(&current_wc, &desired_wc, ApplyMode::Merge);
let steps = configs_diff
.upserts
.into_iter()
.map(|(g, c)| (g, Some(c)))
.chain(configs_diff.deletes.into_iter().map(|g| (g, None)));
for (group, config) in steps {
let mut diff = instance_config::ConfigsDiff::default();
let (operation, action_kind) = match config {
Some(config) => {
diff.upserts.insert(group.clone(), config);
("worker_config.update", ActionKind::Update)
}
None => {
diff.deletes.push(group.clone());
("worker_config.delete", ActionKind::Delete)
}
};
instance_config::apply_configs_diff(&db, &diff)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
audit_log(
&db,
&authed,
operation,
action_kind,
"global",
Some(&format!("worker__{group}")),
None,
)
.await?;
}
instance_config::apply_configs_diff(
&db,
&configs_diff,
&InstanceConfigAuditor { db: &db, author: &authed, source: None },
)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
}
Ok(())
+6 -1
View File
@@ -20,7 +20,7 @@ benchmark = []
embedding = ["windmill-api-embeddings/embedding"]
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-indexer?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "windmill-api-npm-proxy/parquet", "dep:aws-sigv4", "dep:aws-sdk-config", "dep:quick-xml"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus", "windmill-api-scripts/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"]
openidconnect = ["dep:openidconnect", "dep:rsa", "dep:p256", "dep:tokio-rustls", "windmill-common/openidconnect", "windmill-store/openidconnect"]
# The service log search handler reads the columnar store, so tantivy alone is
# not enough for this crate to build on its own.
tantivy = ["dep:windmill-indexer", "parquet"]
@@ -141,6 +141,8 @@ samael = { workspace = true, optional = true }
libxml = { workspace = true, optional = true }
async-recursion.workspace = true
rsa = { workspace = true, optional = true}
p256 = { workspace = true, optional = true }
tokio-rustls = { workspace = true, optional = true }
uuid.workspace = true
datafusion = { workspace = true, optional = true}
openidconnect = { workspace = true, optional = true}
@@ -189,4 +191,7 @@ flate2.workspace = true
strum = { workspace = true, optional = true }
dashmap.workspace = true
[dev-dependencies]
rcgen.workspace = true
[build-dependencies]
+66
View File
@@ -7436,6 +7436,72 @@ paths:
items:
$ref: "#/components/schemas/TruncatedToken"
/oidc/signing_status:
get:
summary: what signs OIDC tokens and the keys the JWKS serves (ee only, superadmin)
operationId: getOidcSigningStatus
tags:
- oidc
responses:
"200":
description: OIDC signing status
content:
application/json:
schema:
type: object
required:
- mode
- keys
properties:
mode:
type: string
enum: [database, file, external]
keys:
type: array
items:
type: object
required:
- alg
- state
- source
properties:
kid:
type: string
alg:
type: string
state:
type: string
enum: [pending, active, retired, published]
source:
type: string
enum: [signer, database, file, publish_dir]
signer:
type: object
description: set when an external signer is configured
required:
- breaker
- max_rps
- current_rps
properties:
last_refresh_success:
type: string
format: date-time
last_refresh_error:
type: string
breaker:
type: string
enum: [closed, open, half_open]
max_rps:
type: number
current_rps:
type: number
keys_from_store:
type: boolean
description: the keys served are those stored by the last accepted listing, the signer has not answered this server yet
tls_files_error:
type: string
description: why the mTLS files on disk are not the ones in use
/w/{workspace}/oidc/token/{audience}:
post:
summary: get OIDC token (ee only)
+121 -46
View File
@@ -4393,10 +4393,55 @@ lazy_static! {
/// change or deletion, which don't bump the version — see
/// `invalidate_app_policy_cache` and `process_notify_event`);
/// - the entry is within its TTL (backstop for a missed event or a restart).
static ref APP_POLICY_CACHE: cache::Cache<(String, String), (Option<i64>, Arc<Policy>, std::time::Instant)> =
static ref APP_POLICY_CACHE: cache::Cache<(String, String), (Option<i64>, Arc<DeployedPolicy>, std::time::Instant)> =
cache::Cache::new(1000);
}
/// What `execute_component` runs a deployed app against.
struct DeployedPolicy {
policy: Policy,
/// Runnable id -> argument -> context property (`username`, `email`, `groups`,
/// `workspace`, `author`), for every `ctx` field the app's runnables declare. Read
/// from the deployed app rather than taken from the request, which only says what
/// the browser chose to send: a runnable filters rows on these. A deploy that
/// changes them under an unchanged policy evicts nothing, so a caller still naming
/// the replaced version keeps the previous declarations until the entry's TTL.
ctx_inputs: HashMap<String, HashMap<String, String>>,
}
async fn deployed_ctx_inputs(
db: &DB,
w_id: &str,
path: &str,
) -> Result<HashMap<String, HashMap<String, String>>> {
let runnables = sqlx::query_scalar::<_, Option<sqlx::types::Json<serde_json::Value>>>(
"SELECT app_version.value -> 'runnables' FROM app
JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]
WHERE app.path = $1 AND app.workspace_id = $2",
)
.bind(path)
.bind(w_id)
.fetch_optional(db)
.await?
.flatten();
let Some(serde_json::Value::Object(runnables)) = runnables.map(|r| r.0) else {
return Ok(HashMap::new());
};
Ok(runnables
.into_iter()
.filter_map(|(id, runnable)| {
let ctx_inputs: HashMap<String, String> = runnable
.get("fields")?
.as_object()?
.iter()
.filter(|(_, f)| f.get("type").and_then(|t| t.as_str()) == Some("ctx"))
.filter_map(|(arg, f)| Some((arg.clone(), f.get("ctx")?.as_str()?.to_string())))
.collect();
(!ctx_inputs.is_empty()).then_some((id, ctx_inputs))
})
.collect())
}
/// Backstop time-to-live for an [`APP_POLICY_CACHE`] entry — the maximum a policy
/// change can go unreflected if its invalidation event is never consumed.
const APP_POLICY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
@@ -4449,7 +4494,7 @@ async fn execute_component(
_ => {}
};
let arc_policy: Arc<Policy>;
let arc_policy: Arc<DeployedPolicy>;
let policy_triggerables_default = Default::default();
// Preview mode means the request was issued from the editor; the editing
// user is already trusted by the policy check, so client-supplied `tag`
@@ -4532,7 +4577,7 @@ async fn execute_component(
// - The policy and triggerables are fetched from the database.
// - In case of inline script, if an entry exists in the `app_script` table, push
// an `AppScript` job payload, as in (.1) otherwise.
let (policy, policy_triggerables) = match payload {
let (policy, policy_triggerables, ctx_inputs) = match payload {
// 1. "preview" mode.
ExecuteApp {
force_viewer_static_fields: Some(static_inputs),
@@ -4551,6 +4596,7 @@ async fn execute_component(
sensitive_inputs: force_viewer_sensitive_inputs.unwrap_or_default(),
tag: None,
},
None,
),
// 2. "run" mode.
_ => {
@@ -4580,9 +4626,12 @@ async fn execute_component(
.map(|(_, p, _)| p);
let policy = if let Some(p) = fresh {
arc_policy = p;
&*arc_policy
&arc_policy.policy
} else {
arc_policy = Arc::new(policy_fut.await?);
arc_policy = Arc::new(DeployedPolicy {
policy: policy_fut.await?,
ctx_inputs: deployed_ctx_inputs(&db, &w_id, path).await?,
});
APP_POLICY_CACHE.insert(
cache_key,
(
@@ -4591,7 +4640,7 @@ async fn execute_component(
std::time::Instant::now(),
),
);
&*arc_policy
&arc_policy.policy
};
// Caller-supplied inline code (`raw_code`), with or without an
@@ -4653,7 +4702,11 @@ async fn execute_component(
})
.ok_or_else(|| Error::BadRequest(format!("Path {path} forbidden by policy")))?;
(policy, policy_triggerables)
(
policy,
policy_triggerables,
arc_policy.ctx_inputs.get(&payload.component),
)
}
};
@@ -4731,6 +4784,7 @@ async fn execute_component(
let (mut args, job_id) = build_args(
policy,
policy_triggerables,
ctx_inputs,
payload.args,
opt_authed.as_ref(),
&user_db,
@@ -6161,6 +6215,54 @@ fn policy_granted_viewer(authed: &ApiAuthed) -> ApiAuthed {
ApiAuthed { scopes: None, ..authed.clone() }
}
/// The value of an app context property for this run. Everything about the viewer comes
/// from `authed`, the session the request authenticated with; an anonymous run has none
/// and gets `null`.
async fn ctx_value(
prop: &str,
policy: &Policy,
authed: Option<&ApiAuthed>,
w_id: &str,
db: &DB,
) -> Result<Box<RawValue>> {
let value = match prop {
"username" => authed.map(|a| serde_json::to_value(a.display_username())),
"email" => authed.map(|a| serde_json::to_value(&a.email)),
"workspace" => Some(serde_json::to_value(w_id)),
"groups" => authed.map(|a| serde_json::to_value(&a.groups)),
// Same rule as `get_on_behalf_of`: the stored address, derived only when absent.
"author" => {
let author = match (
policy.on_behalf_of_email.as_deref(),
policy.on_behalf_of.as_deref(),
) {
(Some(email), _) => Some(email.to_string()),
(None, Some(permissioned_as)) => Some(
windmill_common::users::get_email_from_permissioned_as(
permissioned_as,
w_id,
db,
)
.await?,
),
(None, None) => None,
};
Some(serde_json::to_value(&author))
}
_ => {
return Err(Error::BadRequest(format!(
"context variable {} not allowed",
prop
)))
}
};
Ok(to_raw_value(
&value.unwrap_or(Ok(serde_json::Value::Null)).map_err(|e| {
Error::internal_err(format!("failed to serialize ctx variable {}: {}", prop, e))
})?,
))
}
async fn build_args(
policy: &Policy,
PolicyTriggerableInputs {
@@ -6170,6 +6272,7 @@ async fn build_args(
sensitive_inputs,
..
}: &PolicyTriggerableInputs,
ctx_inputs: Option<&HashMap<String, String>>,
mut args: HashMap<String, Box<RawValue>>,
authed: Option<&ApiAuthed>,
user_db: &UserDB,
@@ -6179,6 +6282,16 @@ async fn build_args(
let mut job_id: Option<Uuid> = None;
let mut safe_args = HashMap::<String, Box<RawValue>>::new();
// Before anything reads `args`: a context input the runnable declares is never taken
// from the request, or a viewer could name someone else as themselves.
for (k, prop) in ctx_inputs.into_iter().flatten() {
args.remove(k);
safe_args.insert(
k.to_string(),
ctx_value(prop, policy, authed, w_id, db).await?,
);
}
// tracing::error!("{:?}", allow_user_resources);
for k in allow_user_resources.iter() {
if let Some(arg_val) = args.get(k) {
@@ -6270,47 +6383,9 @@ async fn build_args(
if arg_str.starts_with("\"$ctx:") {
let prop = arg_str.trim_start_matches("\"$ctx:").trim_end_matches("\"");
let value = match prop {
"username" => authed
.as_ref()
.map(|a| serde_json::to_value(a.display_username())),
"email" => authed.as_ref().map(|a| serde_json::to_value(&a.email)),
"workspace" => Some(serde_json::to_value(&w_id)),
"groups" => authed.as_ref().map(|a| serde_json::to_value(&a.groups)),
// Same rule as `get_on_behalf_of`: the stored address, derived only when absent.
"author" => {
let author = match (
policy.on_behalf_of_email.as_deref(),
policy.on_behalf_of.as_deref(),
) {
(Some(email), _) => Some(email.to_string()),
(None, Some(permissioned_as)) => Some(
windmill_common::users::get_email_from_permissioned_as(
permissioned_as,
w_id,
db,
)
.await?,
),
(None, None) => None,
};
Some(serde_json::to_value(&author))
}
_ => {
return Err(Error::BadRequest(format!(
"context variable {} not allowed",
prop
)))
}
};
safe_args.insert(
k.to_string(),
to_raw_value(&value.unwrap_or(Ok(serde_json::Value::Null)).map_err(|e| {
Error::internal_err(format!(
"failed to serialize ctx variable for {}: {}",
k, e
))
})?),
ctx_value(prop, policy, authed, w_id, db).await?,
);
} else if !arg_str.contains("\"$var:") && !arg_str.contains("\"$res:") {
safe_args.insert(k.to_string(), v);
+1 -1
View File
@@ -6,7 +6,7 @@
* The build itself is `wmill app bundle`, the same one `wmill app push` runs, so
* an app deployed through the API is compiled exactly as the CLI and the editor
* compile it — entry point, virtual `wmill` module, `/ui/` shared-UI resolution
* and the Svelte/Vue plugins included. Reimplementing any of that here would be
* and the Svelte plugin included. Reimplementing any of that here would be
* a third bundler to keep in step with the other two.
*/
declare const __wmillAppPolicy: {
+2
View File
@@ -149,6 +149,8 @@ pub mod oauth2_oss;
#[cfg(feature = "private")]
pub mod oidc_ee;
mod oidc_oss;
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
pub mod oidc_signer_tools_ee;
mod path_autocomplete;
mod raw_apps;
mod resources;
+18 -10
View File
@@ -23,14 +23,12 @@ async fn with_deadline<T>(
what: &str,
fut: impl std::future::Future<Output = Result<T>>,
) -> Result<T> {
tokio::time::timeout(MCP_DEADLINE, fut)
.await
.map_err(|_| {
Error::ExecutionErr(format!(
"MCP server did not answer within {}s ({what})",
MCP_DEADLINE.as_secs()
))
})?
tokio::time::timeout(MCP_DEADLINE, fut).await.map_err(|_| {
Error::ExecutionErr(format!(
"MCP server did not answer within {}s ({what})",
MCP_DEADLINE.as_secs()
))
})?
}
/// Connect to the MCP server described by the `mcp` resource at `path`.
@@ -65,8 +63,9 @@ async fn connect_mcp_client(
let resource_value = not_found_if_none(resource_value_o, "Resource", path)?
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", path)))?;
let mcp_resource = serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
let mut mcp_resource =
serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
#[cfg(feature = "oauth2")]
{
@@ -126,6 +125,15 @@ async fn connect_mcp_client(
None
};
// Header secrets go through the same permissioned path as the token.
for value in mcp_resource.headers.iter_mut().flatten().map(|(_, v)| v) {
if let Some(var_path) = value.strip_prefix("$var:") {
check_scopes(authed, || format!("variables:read:{}", var_path))?;
let db_authed = DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone()));
*value = get_value_internal(&db_authed, w_id, var_path, false).await?;
}
}
windmill_mcp::McpClient::from_resource(mcp_resource, token)
.await
.map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e)))
@@ -0,0 +1,148 @@
//! Audit entries for writes to instance settings and worker group configs, whichever way
//! they arrive: an API request, or a declarative sync that no user request stands behind.
use std::collections::HashMap;
use windmill_common::{
audit::AuditAuthor, error::Result, instance_config::ConfigWriteObserver,
users::SUPERADMIN_SYNC_EMAIL, DB,
};
use crate::{
audit_oss::{audit_log, AuditAuthorable},
ActionKind,
};
/// The declarative writer behind an entry, recorded as its `source` parameter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeclarativeSource {
/// `windmill sync-config <file>`
SyncConfig,
/// The Kubernetes operator's ConfigMap sync.
Operator,
}
impl DeclarativeSource {
pub fn as_str(&self) -> &'static str {
match self {
DeclarativeSource::SyncConfig => "sync-config",
DeclarativeSource::Operator => "operator",
}
}
}
/// Who a declarative sync's writes are attributed to. It runs on direct database
/// credentials, so there is no user to name.
pub fn declarative_sync_author() -> AuditAuthor {
AuditAuthor {
username: "instance_config_sync".to_string(),
email: SUPERADMIN_SYNC_EMAIL.to_string(),
username_override: None,
token_prefix: None,
}
}
fn parameters<'a>(
value: Option<&'a str>,
source: Option<DeclarativeSource>,
) -> Option<HashMap<&'a str, &'a str>> {
let parameters: HashMap<&str, &str> = value
.map(|v| ("value", v))
.into_iter()
.chain(source.map(|s| ("source", s.as_str())))
.collect();
(!parameters.is_empty()).then_some(parameters)
}
/// A setting is recorded by name, with its value only when that is a boolean or a number.
/// Any other shape can carry a credential (a registry URL, a webhook, a header map), and no
/// list of secret-bearing settings stays complete as settings are added.
///
/// AUTHORIZATION: records a write, checks nothing. The caller must have established that
/// `author` may change instance settings before performing the write this records.
pub async fn audit_setting_write(
db: &DB,
author: &impl AuditAuthorable,
key: &str,
value: Option<&serde_json::Value>,
source: Option<DeclarativeSource>,
) -> Result<()> {
let deleted = match value {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(s)) => s.trim().is_empty(),
Some(_) => false,
};
let recorded = match value {
Some(v @ (serde_json::Value::Bool(_) | serde_json::Value::Number(_))) => {
Some(v.to_string())
}
_ => None,
};
audit_log(
db,
author,
if deleted {
"global_settings.delete"
} else {
"global_settings.update"
},
if deleted {
ActionKind::Delete
} else {
ActionKind::Update
},
"global",
Some(key),
parameters(recorded.as_deref(), source),
)
.await
}
/// A worker group config is recorded by name only: its env vars and init script are free
/// text that can carry a credential.
///
/// AUTHORIZATION: records a write, checks nothing. The caller must have established that
/// `author` may change worker group configs before performing the write this records.
pub async fn audit_worker_config_write(
db: &DB,
author: &impl AuditAuthorable,
group: &str,
deleted: bool,
source: Option<DeclarativeSource>,
) -> Result<()> {
let (operation, action_kind) = if deleted {
("worker_config.delete", ActionKind::Delete)
} else {
("worker_config.update", ActionKind::Update)
};
audit_log(
db,
author,
operation,
action_kind,
"global",
Some(&format!("worker__{group}")),
parameters(None, source),
)
.await
}
/// Audits each write of an instance config apply as `author`'s.
///
/// AUTHORIZATION: as [`audit_setting_write`], for every write it is told of.
pub struct InstanceConfigAuditor<'a, A> {
pub db: &'a DB,
pub author: &'a A,
/// `None` for a write made through the API.
pub source: Option<DeclarativeSource>,
}
impl<A: AuditAuthorable + Sync> ConfigWriteObserver for InstanceConfigAuditor<'_, A> {
async fn setting_written(&self, key: &str, value: Option<&serde_json::Value>) -> Result<()> {
audit_setting_write(self.db, self.author, key, value, self.source).await
}
async fn worker_config_written(&self, group: &str, deleted: bool) -> Result<()> {
audit_worker_config_write(self.db, self.author, group, deleted, self.source).await
}
}
+1
View File
@@ -4,6 +4,7 @@ use sqlx::FromRow;
#[cfg(feature = "private")]
pub mod audit_ee;
pub mod audit_oss;
pub mod instance_config;
#[derive(sqlx::Type, Serialize, Deserialize, Debug)]
#[sqlx(type_name = "ACTION_KIND", rename_all = "lowercase")]
+2 -1
View File
@@ -22,7 +22,7 @@ smtp = ["dep:mail-send", "dep:rustls", "dep:tokio-rustls", "dep:rustls-native-ce
scoped_cache = []
cloud = []
dev_override = []
openidconnect = ["dep:openidconnect", "dep:rsa"]
openidconnect = ["dep:openidconnect", "dep:rsa", "dep:p256"]
python = ["dep:windmill-parser-py"]
[lib]
@@ -116,6 +116,7 @@ pep440_rs.workspace = true
systemstat.workspace = true
size.workspace = true
rsa = { workspace = true, optional = true }
p256 = { workspace = true, optional = true }
spki = { workspace = true }
pkcs1 = { workspace = true }
aes-gcm = { workspace = true, optional = true }
@@ -198,7 +198,8 @@ pub async fn external_instance_database_usages<'c>(
/// would be a login nothing can drop any more. Allowed on every edition, so a
/// downgraded instance can still clear a setting it no longer uses.
async fn ensure_external_instance_pg_removable(conn: &mut sqlx::PgConnection) -> Result<()> {
ensure_external_instance_pg_unused(conn, &format!("removing {EXTERNAL_INSTANCE_PG_SETTING}")).await
ensure_external_instance_pg_unused(conn, &format!("removing {EXTERNAL_INSTANCE_PG_SETTING}"))
.await
}
/// Refuse while Windmill has databases or data table roles on the cluster, or a workspace points
@@ -394,16 +395,21 @@ pub async fn write_external_instance_pg_setting(
}
/// [`write_external_instance_pg_setting`] for a settings diff: writes the key if the diff touches
/// it, and takes it out of the diff so the generic apply does not write it again.
/// it, and takes it out of the diff so the generic apply does not write it again. `observer` is
/// told of the write here, since the generic apply no longer sees the key.
///
/// Authorization: checks nothing. Callers MUST be superadmin, or the declarative instance config
/// sync, which applies what the operator deployed.
pub async fn write_external_instance_pg_from_diff(
db: &DB,
diff: &mut crate::instance_config::SettingsDiff,
observer: &impl crate::instance_config::ConfigWriteObserver,
) -> Result<()> {
if let Some(value) = diff.upserts.remove(EXTERNAL_INSTANCE_PG_SETTING) {
write_external_instance_pg_setting(db, Some(&value)).await?;
observer
.setting_written(EXTERNAL_INSTANCE_PG_SETTING, Some(&value))
.await?;
}
if let Some(i) = diff
.deletes
@@ -412,6 +418,9 @@ pub async fn write_external_instance_pg_from_diff(
{
diff.deletes.remove(i);
write_external_instance_pg_setting(db, None).await?;
observer
.setting_written(EXTERNAL_INSTANCE_PG_SETTING, None)
.await?;
}
Ok(())
}
+73 -7
View File
@@ -1015,6 +1015,43 @@ pub struct ConfigsDiff {
pub deletes: Vec<String>,
}
/// Told of each write an instance config apply makes, as soon as it has landed: the writes
/// are not transactional, so an apply that fails midway must not leave the ones it did make
/// unrecorded. The audit entry is the implementor's to write, as this crate sits below
/// `windmill-audit`.
pub trait ConfigWriteObserver: Sync {
/// `value` is `None` for a deleted setting.
fn setting_written(
&self,
key: &str,
value: Option<&serde_json::Value>,
) -> impl std::future::Future<Output = crate::error::Result<()>> + Send;
fn worker_config_written(
&self,
group: &str,
deleted: bool,
) -> impl std::future::Future<Output = crate::error::Result<()>> + Send;
}
impl ConfigWriteObserver for () {
async fn setting_written(
&self,
_key: &str,
_value: Option<&serde_json::Value>,
) -> crate::error::Result<()> {
Ok(())
}
async fn worker_config_written(
&self,
_group: &str,
_deleted: bool,
) -> crate::error::Result<()> {
Ok(())
}
}
/// Settings that must never be deleted by the operator or bulk API.
pub const PROTECTED_SETTINGS: &[&str] = &[
"ducklake_user_pg_pwd",
@@ -1026,6 +1063,8 @@ pub const PROTECTED_SETTINGS: &[&str] = &[
"rsa_keys",
"jwt_secret",
"min_keep_alive_version",
// Deleting it would let a worker sign OIDC tokens with a local key again.
"oidc_external_signer",
];
/// Secrets the server signs with or generated for itself. `jwt_secret` signs API and job
@@ -1079,6 +1118,8 @@ pub const HIDDEN_SETTINGS: &[&str] = &[
"custom_instance_replication_pwd",
// Same for the passwords and database registry Windmill keeps for the external cluster.
"external_instance_pg_state",
// Written by servers that sign OIDC tokens with an external signer.
"oidc_external_signer",
];
/// Top-level settings whose entire value is sensitive and must be fully redacted in logs.
@@ -1398,10 +1439,14 @@ pub fn diff_worker_configs(
/// AUTHORIZATION: replaces instance-wide settings and takes no authed context, so
/// callers MUST have established superadmin or equivalent system authority (the CLI
/// and the operator both run with direct instance credentials).
///
/// `observer` is what audits the sync: no user request stands behind these writes, so
/// nothing else records them.
pub async fn sync_global_settings_declarative(
db: &sqlx::Pool<sqlx::Postgres>,
current: &BTreeMap<String, serde_json::Value>,
desired: &BTreeMap<String, serde_json::Value>,
observer: &impl ConfigWriteObserver,
) -> anyhow::Result<()> {
for url_key in [
crate::global_settings::GITHUB_APP_WEBHOOK_BASE_URL_SETTING,
@@ -1465,16 +1510,22 @@ pub async fn sync_global_settings_declarative(
.map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?;
let mut diff = diff_global_settings(current, desired, ApplyMode::Replace);
crate::external_instance_pg::write_external_instance_pg_from_diff(db, &mut diff).await?;
apply_settings_diff(db, &diff).await?;
crate::external_instance_pg::write_external_instance_pg_from_diff(db, &mut diff, observer)
.await?;
apply_settings_diff(db, &diff, observer).await?;
Ok(())
}
/// Apply a settings diff to the global_settings table.
/// Apply a settings diff to the global_settings table, reporting each write to `observer`.
///
/// AUTHORIZATION: writes instance-wide settings, credentials included, and checks
/// nothing; `observer` records and authorizes nothing. Callers MUST have established
/// superadmin or equivalent system authority.
pub async fn apply_settings_diff(
db: &sqlx::Pool<sqlx::Postgres>,
diff: &SettingsDiff,
observer: &impl ConfigWriteObserver,
) -> anyhow::Result<()> {
for (key, value) in &diff.upserts {
sqlx::query(
@@ -1497,6 +1548,7 @@ pub async fn apply_settings_diff(
format_setting_value(key, value)
);
}
observer.setting_written(key, Some(value)).await?;
}
for key in &diff.deletes {
sqlx::query("DELETE FROM global_settings WHERE name = $1")
@@ -1511,6 +1563,7 @@ pub async fn apply_settings_diff(
} else {
tracing::info!("Deleted global setting: {key}");
}
observer.setting_written(key, None).await?;
}
if diff.unchanged_count > 0 {
tracing::info!("{} global setting(s) unchanged", diff.unchanged_count);
@@ -1518,10 +1571,16 @@ pub async fn apply_settings_diff(
Ok(())
}
/// Apply a configs diff to the config table (worker__ prefix).
/// Apply a configs diff to the config table (worker__ prefix), reporting each write to
/// `observer`.
///
/// AUTHORIZATION: writes worker group configs, whose init scripts run on the workers, and
/// checks nothing; `observer` records and authorizes nothing. Callers MUST have
/// established superadmin or equivalent system authority.
pub async fn apply_configs_diff(
db: &sqlx::Pool<sqlx::Postgres>,
diff: &ConfigsDiff,
observer: &impl ConfigWriteObserver,
) -> anyhow::Result<()> {
for (group_name, config_value) in &diff.upserts {
let db_key = format!("worker__{group_name}");
@@ -1534,6 +1593,7 @@ pub async fn apply_configs_diff(
.execute(db)
.await?;
tracing::info!("Synced worker config: {db_key}");
observer.worker_config_written(group_name, false).await?;
}
for group_name in &diff.deletes {
let db_key = format!("worker__{group_name}");
@@ -1542,6 +1602,7 @@ pub async fn apply_configs_diff(
.execute(db)
.await?;
tracing::info!("Deleted worker config: {db_key}");
observer.worker_config_written(group_name, true).await?;
}
Ok(())
}
@@ -1663,7 +1724,11 @@ impl InstanceConfig {
///
/// Reads the current state, computes a diff, then applies upserts and
/// deletes for both global settings and worker configs.
pub async fn sync_to_db(&self, db: &sqlx::Pool<sqlx::Postgres>) -> anyhow::Result<()> {
pub async fn sync_to_db(
&self,
db: &sqlx::Pool<sqlx::Postgres>,
observer: &impl ConfigWriteObserver,
) -> anyhow::Result<()> {
let desired_settings = self.global_settings.to_settings_map();
// Current global settings
@@ -1673,7 +1738,8 @@ impl InstanceConfig {
.await?;
let current_settings: BTreeMap<String, serde_json::Value> = rows.into_iter().collect();
sync_global_settings_declarative(db, &current_settings, &desired_settings).await?;
sync_global_settings_declarative(db, &current_settings, &desired_settings, observer)
.await?;
// Worker configs
let desired_configs: BTreeMap<String, serde_json::Value> = self
@@ -1701,7 +1767,7 @@ impl InstanceConfig {
let configs_diff =
diff_worker_configs(&current_configs, &desired_configs, ApplyMode::Replace);
apply_configs_diff(db, &configs_diff).await?;
apply_configs_diff(db, &configs_diff, observer).await?;
Ok(())
}
+2
View File
@@ -102,6 +102,8 @@ pub mod oauth2;
pub mod oidc_ee;
#[cfg(all(feature = "enterprise", feature = "openidconnect"))]
pub mod oidc_oss;
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
pub mod oidc_signer_ee;
#[cfg(feature = "private")]
pub mod otel_ee;
pub mod otel_oss;
+13
View File
@@ -54,6 +54,19 @@ pub(crate) fn init_logs_bridge(_mode: &Mode, _hostname: &str, _env: &str) -> Opt
None
}
#[cfg(not(feature = "private"))]
pub(crate) fn init_logs_bridge_for_service(
_service: &str,
_hostname: &str,
_env: &str,
) -> Option<EnvFilter> {
None
}
/// Export the log records still batched, for a process about to exit.
#[cfg(not(feature = "private"))]
pub async fn flush_otel_logs() {}
#[cfg(all(feature = "otel", feature = "enterprise", not(feature = "private")))]
pub(crate) fn init_otlp_tracer(
_mode: &Mode,
@@ -220,6 +220,28 @@ pub fn initialize_tracing(
(_guard, meter_provider)
}
/// Tracing for a subcommand that writes to the instance without running a server
/// (`sync-config`, the operator): stdout, plus the OTel logs bridge when OTel logs are
/// enabled, which is how the audit events of its writes reach a collector. It writes no
/// service log files, as nothing lists such a process to read them back.
///
/// Only audit events are exported. These processes log each setting they write with its
/// value, masked by a list of known secret fields rather than by shape, and that is fit
/// for the operator's own stdout but not for a collector.
pub fn initialize_subcommand_tracing(service: &str, hostname: &str, environment: &str) {
let logs_bridge = crate::otel_oss::init_logs_bridge_for_service(service, hostname, environment);
tracing_subscriber::registry()
.with(logs_bridge.with_filter(Targets::new().with_target(AUDIT_TARGET, LevelFilter::INFO)))
.with(
tracing_subscriber::fmt::layer().with_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
),
)
.init();
}
lazy_static::lazy_static! {
pub static ref LOG_COUNTING_BY_MIN: Arc<RwLock<HashMap<String, LogCounter>>> = Arc::new(RwLock::new(HashMap::new()));
}
+2 -1
View File
@@ -70,7 +70,8 @@ impl McpClient {
headers.insert(name, value);
}
_ => {
tracing::warn!("Invalid header: {}={}", key, value);
// The value may be a resolved secret: keep it out of the logs.
tracing::warn!("Invalid header: {}", key);
}
}
}
+1
View File
@@ -19,6 +19,7 @@ serde_json.workspace = true
sqlx.workspace = true
tracing.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-audit.workspace = true
anyhow.workspace = true
thiserror.workspace = true
kube.workspace = true
+45 -4
View File
@@ -103,9 +103,9 @@ pub fn filter_schema_by_input_transforms(
.get_mut("properties")
.and_then(|p| p.as_object_mut())
{
for key in &keys_to_remove {
properties.remove(key);
}
// `remove` swaps the last entry into the freed slot, so removing in set order
// would leave the survivors in a different order on every run.
properties.retain(|key, _| !keys_to_remove.contains(key));
}
// Also remove from required array
@@ -570,7 +570,7 @@ pub async fn load_mcp_tools(
// is not allowed to read (resources:read:{path}). Reading through the raw
// db pool here would bypass the authorization enforced by the regular MCP
// tools API (get_mcp_tools) and act as a confused deputy.
let mcp_resource = client
let mut mcp_resource = client
.get_resource_value::<McpResource>(path)
.await
.map_err(|e| {
@@ -615,6 +615,18 @@ pub async fn load_mcp_tools(
None
};
// Header secrets go through the same permissioned path as the token.
for (name, value) in mcp_resource.headers.iter_mut().flatten() {
if let Some(var_path) = value.strip_prefix("$var:") {
*value = client.get_variable_value(var_path).await.map_err(|e| {
Error::internal_err(format!(
"Failed to resolve variable {} for header {} of MCP resource {}: {}",
var_path, name, resource_name, e
))
})?;
}
}
// Create new MCP client for this execution
tracing::debug!("Creating fresh MCP client for {}", resource_name);
let mcp_conn = McpClient::from_resource(mcp_resource, token)
@@ -736,3 +748,32 @@ pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
false
})
}
#[cfg(test)]
mod tests {
use super::*;
/// A tool's schema sits in the prompt prefix the provider caches on, so the
/// parameters left for the model must keep their order on every run.
#[test]
fn filtering_preset_inputs_keeps_the_remaining_property_order() {
let keys = ["a", "b", "c", "d", "e", "f", "g", "h"];
let props = keys
.iter()
.map(|k| format!(r#""{k}":{{"type":"string"}}"#))
.collect::<Vec<_>>()
.join(",");
let schema =
RawValue::from_string(format!(r#"{{"type":"object","properties":{{{props}}}}}"#))
.unwrap();
let preset = ["a", "c", "f"]
.iter()
.map(|k| (k.to_string(), InputTransform::Static { value: to_raw_value(&1) }))
.collect();
let filtered = filter_schema_by_input_transforms(schema, &preset).unwrap();
let value: serde_json::Value = serde_json::from_str(filtered.get()).unwrap();
let remaining: Vec<&String> = value["properties"].as_object().unwrap().keys().collect();
assert_eq!(remaining, ["b", "d", "e", "g", "h"]);
}
}
@@ -873,7 +873,8 @@ fn convert_typ_val(arg_t: String, arg_v: Value) -> Value {
let mut v = arg_v;
if !v.is_string() {
// if not string, convert to string for api request
// The SQL API has no ARRAY/VARIANT binding type: a list or object
// is bound as its JSON text, which the query reads with PARSE_JSON(?).
v = json!(v.to_string());
}
@@ -935,3 +936,16 @@ fn parse_val(value: &Value, typ: &str) -> Value {
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_arg_binds_as_json_text() {
assert_eq!(
convert_typ_val("varchar[]".to_string(), json!(["sales", "g/ops"])),
json!({ "type": "TEXT", "value": r#"["sales","g/ops"]"# })
);
}
}
+124 -6
View File
@@ -32,6 +32,8 @@ export interface AppFile {
guests?: boolean;
value: any;
public?: boolean;
sandbox?: boolean;
frontend_api?: string[];
summary: string;
policy: Policy;
// Mirrors granular ACLs on the app path. Omitted from app.yaml when no
@@ -160,6 +162,100 @@ export function executionModeForPush(
}
return deployedPolicy?.execution_mode === "viewer" ? "viewer" : "publisher";
}
/** Mirrors `FRONTEND_SDK_ALLOWED_SCOPES` in the backend's `apps.rs`, which stays
* the authority: this copy only fails a push before it bundles. */
export const FRONTEND_API_SCOPES = [
"jobs:run",
"jobs:read",
"users:read",
"resources:read",
"variables:read",
"flow_conversations:read",
"flow_conversations:write",
];
export type AppIsolation = { sandbox: boolean; frontendApi: string[] };
/** Write `sandbox` and `frontend_api` on a pulled app, only when they are on, so
* an app using neither keeps the file it had. Scopes of an unsandboxed app are
* inert and stay out: a file stating them without `sandbox` does not push. */
export function markIsolationFromPolicy(app: any, rawApp: boolean) {
if (app?.["policy"]?.["sandbox"] !== true) {
return;
}
app.sandbox = true;
const scopes = app.policy.frontend_sdk_scopes;
if (rawApp && Array.isArray(scopes) && scopes.length > 0) {
app.frontend_api = scopes;
}
}
/** The isolation the tracked file states, or `undefined` when it has no `sandbox`
* key and the deployed policy carries over. A file that has the key states both
* fields: `sandbox: false` turns isolation off, and a missing `frontend_api`
* means no scopes. */
export function statedIsolation(
localApp: any,
rawApp: boolean,
): AppIsolation | undefined {
const sandbox = localApp?.["sandbox"];
const scopes = localApp?.["frontend_api"] ?? [];
if (sandbox !== undefined && typeof sandbox !== "boolean") {
throw new Error("'sandbox' must be true or false");
}
if (
!Array.isArray(scopes) ||
scopes.some((s: unknown) => typeof s !== "string")
) {
throw new Error("'frontend_api' must be a list of scopes");
}
if (scopes.length > 0 && !rawApp) {
throw new Error("'frontend_api' is only available to full-code apps");
}
const seen = new Set<string>();
for (const scope of scopes) {
if (!FRONTEND_API_SCOPES.includes(scope)) {
throw new Error(
`Invalid frontend_api scope '${scope}'. Allowed scopes: ${FRONTEND_API_SCOPES.join(", ")}`,
);
}
if (seen.has(scope)) {
throw new Error(`Duplicate frontend_api scope '${scope}'`);
}
seen.add(scope);
}
if (scopes.length > 0 && sandbox !== true) {
throw new Error(
"'frontend_api' requires 'sandbox: true': an unsandboxed app gets no frontend API token",
);
}
return sandbox === undefined ? undefined : { sandbox, frontendApi: scopes };
}
/** Whether the deployed policy already has the stated isolation. The file's two
* keys are kept out of the superset comparison against the deployed app, which
* could not see a key the file dropped. */
export function isolationUpToDate(
isolation: AppIsolation | undefined,
deployedPolicy: Policy | undefined,
rawApp: boolean,
): boolean {
if (!isolation) {
return true;
}
if ((deployedPolicy?.sandbox === true) !== isolation.sandbox) {
return false;
}
if (!rawApp) {
return true;
}
const deployed = deployedPolicy?.frontend_sdk_scopes ?? [];
return (
deployed.length === isolation.frontendApi.length &&
deployed.every((s, i) => s === isolation.frontendApi[i])
);
}
export async function pushApp(
workspace: string,
remotePath: string,
@@ -198,6 +294,7 @@ export async function pushApp(
}
const path = localPath + "app.yaml";
const localApp = (await yamlParseFile(path)) as AppFile;
const isolation = statedIsolation(localApp, false);
replaceInlineScripts(localApp.value, localPath, true);
// On create the backend applies folder defaults, so there is nothing to preserve.
@@ -210,17 +307,26 @@ export async function pushApp(
localApp,
remotePath,
executionModeForPush(localApp, deployedPolicy),
basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of)
basePolicy(localApp, deployedPolicy, !!preserveFields.preserve_on_behalf_of),
isolation
);
// extra_perms goes through /acls/* — strip from the body so a perms-only
// edit never bumps the app version (see applyExtraPermsDiff for details).
const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & {
const {
extra_perms: localPerms,
sandbox: _sandbox,
frontend_api: _frontendApi,
...localAppBody
} = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
if (isSuperset(localAppBody, app)) {
if (
isSuperset(localAppBody, app) &&
isolationUpToDate(isolation, deployedPolicy, false)
) {
log.info(colors.green(`App ${remotePath} is up to date`));
} else {
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
@@ -269,12 +375,13 @@ export async function generatingPolicy(
app: any,
path: string,
executionMode: AppExecutionMode,
base: Policy | undefined
base: Policy | undefined,
isolation?: AppIsolation
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updatePolicy(app.value, base);
finalizeDerivedPolicy(app.policy, executionMode);
finalizeDerivedPolicy(app.policy, executionMode, isolation);
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
@@ -332,10 +439,21 @@ export function preserveOnBehalfOfFields(
* deployed app would keep being able to run runnables this push removed. */
export function finalizeDerivedPolicy(
policy: Policy,
executionMode: AppExecutionMode
executionMode: AppExecutionMode,
isolation?: AppIsolation,
rawApp: boolean = false
) {
policy.triggerables = undefined;
policy.execution_mode = executionMode;
if (isolation) {
// `false` is sent rather than left out: the server reads an omitted value as
// "no opinion" and may sandbox the app anyway, hiding that the file was refused.
policy.sandbox = isolation.sandbox;
if (rawApp) {
policy.frontend_sdk_scopes =
isolation.frontendApi.length > 0 ? isolation.frontendApi : undefined;
}
}
}
async function list(opts: GlobalOptions & { includeDraftOnly?: boolean; json?: boolean }) {
+5 -17
View File
@@ -58,10 +58,10 @@ function conditionsFor(svelte: boolean): string[] {
/**
* Detects which frontend frameworks are present in package.json
*/
export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolean } {
export function detectFrameworks(appDir: string): { svelte: boolean } {
const packageJsonPath = path.join(appDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return { svelte: false, vue: false };
return { svelte: false };
}
try {
@@ -73,10 +73,9 @@ export function detectFrameworks(appDir: string): { svelte: boolean; vue: boolea
return {
svelte: "svelte" in allDeps,
vue: "vue" in allDeps,
};
} catch {
return { svelte: false, vue: false };
return { svelte: false };
}
}
@@ -256,17 +255,6 @@ export async function createFrameworkPlugins(appDir: string): Promise<any[]> {
plugins.push(createSveltePlugin(appDir));
}
if (frameworks.vue) {
log.info(colors.blue("🔧 Vue detected, adding vue plugin..."));
throw new Error("Vue plugin not supported yet");
// try {
// const esbuildPluginVue = await import("esbuild-plugin-vue3");
// plugins.push(esbuildPluginVue.default());
// } catch (error: any) {
// log.warn(colors.yellow(`Failed to load vue plugin: ${error.message}`));
// }
}
return plugins;
}
@@ -313,7 +301,7 @@ export async function createBundle(
// Use the entryPoint's directory if provided, otherwise fall back to cwd.
const appDir = options.entryPoint ? path.dirname(options.entryPoint) : process.cwd();
const frameworks = detectFrameworks(appDir);
const defaultEntry = (frameworks.svelte || frameworks.vue) ? "index.ts" : "index.tsx";
const defaultEntry = frameworks.svelte ? "index.ts" : "index.tsx";
const entryPoint = options.entryPoint ?? defaultEntry;
const outDir = options.outDir ?? "dist";
@@ -331,7 +319,7 @@ export async function createBundle(
// Ensure node_modules exists in the app directory
await ensureNodeModules(appDir);
// Load framework-specific plugins (svelte, vue) based on package.json
// Load framework-specific plugins (svelte) based on package.json
const frameworkPlugins = await createFrameworkPlugins(appDir);
// Ensure output directory exists
+2 -2
View File
@@ -12,10 +12,10 @@ interface BundleOptions {
}
/** Every entry point a raw app may have, most specific first, with the
* framework's preferred one promoted — Svelte and Vue mount from a `.ts`. */
* framework's preferred one promoted — Svelte mounts from a `.ts`. */
function entryPointFor(appDir: string): string {
const frameworks = detectFrameworks(appDir);
const candidates = frameworks.svelte || frameworks.vue
const candidates = frameworks.svelte
? ["index.ts", "index.tsx", "index.js"]
: ["index.tsx", "index.ts", "index.js"];
const entry = candidates.find((c) => fs.existsSync(path.join(appDir, c)));
+3 -3
View File
@@ -481,7 +481,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
// Detect frameworks to determine default entry point
const frameworks = detectFrameworks(process.cwd());
const defaultEntry = frameworks.svelte || frameworks.vue
const defaultEntry = frameworks.svelte
? "index.ts"
: "index.tsx";
const entryPoint = opts.entry ?? defaultEntry;
@@ -577,7 +577,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
const buildOptions = getDevBuildOptions(entryPoint, frameworks.svelte);
// Load framework-specific plugins (svelte, vue) based on package.json
// Load framework-specific plugins (svelte) based on package.json
const frameworkPlugins = await createFrameworkPlugins(appDir);
const wmillPlugin = {
@@ -1602,7 +1602,7 @@ const command = new Command()
})
.option(
"--entry <entry:string>",
"Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)",
"Entry point file (default: index.ts for Svelte, index.tsx otherwise)",
)
.option("--no-open", "Don't automatically open the browser")
.option(
+54 -1
View File
@@ -9,6 +9,8 @@ import { GlobalOptions } from "../../types.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
import { inferSchema } from "../../utils/metadata.ts";
import { statedIsolation } from "./app.ts";
import {
getFolderSuffix,
hasFolderSuffix,
@@ -42,12 +44,62 @@ function validateRawAppYaml(appData: any): {
errors.push("Field 'summary' must be a string");
}
try {
statedIsolation(appData, true);
} catch (error: any) {
errors.push(error.message);
}
// Note: 'runnables' is no longer required in raw_app.yaml
// Runnables can be stored in separate files in the backend folder
return { errors, warnings };
}
const SQL_LANGUAGES = new Set([
"postgresql",
"mysql",
"bigquery",
"snowflake",
"mssql",
"oracledb",
"duckdb",
]);
// The viewer properties a `ctx` field can supply. The Inputs tab of the app
// editor hints on the same names (RawAppInlineScriptRunnable.svelte).
const VIEWER_IDENTITY_ARGS = ["username", "email", "groups"];
/**
* A SQL runnable argument named after a viewer property is most often a
* per-viewer filter. Left as a plain input it takes whatever the browser
* sends, so it has to be a `ctx` field for the server to fill it.
*/
export async function viewerIdentityInputWarnings(
runnables: Record<string, any>,
): Promise<string[]> {
const warnings: string[] = [];
for (const [id, runnable] of Object.entries(runnables)) {
const { language, content } = runnable?.inlineScript ?? {};
if (!SQL_LANGUAGES.has(language) || typeof content !== "string") {
continue;
}
const { schema } = await inferSchema(language, content, {}, id);
for (const arg of VIEWER_IDENTITY_ARGS) {
const field = runnable.fields?.[arg];
const serverFilled = field?.ctx !== undefined ||
field?.value !== undefined;
if (arg in (schema?.properties ?? {}) && !serverFilled) {
warnings.push(
`Runnable '${id}': argument '${arg}' is a frontend input, so a viewer can send any value. ` +
`If it identifies the viewer, set 'fields: { ${arg}: { ctx: ${arg} } }' in backend/${id}.yaml (next to 'type: inline') so the server fills it.`,
);
}
}
}
return warnings;
}
/**
* Validates that runnables exist either in backend/*.yaml files or in raw_app.yaml
*/
@@ -77,6 +129,7 @@ async function validateRunnables(
" - Or a 'runnables' field in raw_app.yaml (legacy format)",
);
} else if (hasBackendRunnables) {
warnings.push(...await viewerIdentityInputWarnings(runnablesFromBackend));
log.info(
colors.gray(
` Found ${
@@ -114,7 +167,7 @@ async function validateBuild(
// Try to create a bundle - this will validate that all dependencies are in place
const frameworks = detectFrameworks(appDir);
const entryFile = frameworks.svelte || frameworks.vue ? "index.ts" : "index.tsx";
const entryFile = frameworks.svelte ? "index.ts" : "index.tsx";
const entryPoint = path.join(appDir, entryFile);
await createBundle({
entryPoint,
+3 -34
View File
@@ -85,21 +85,6 @@ const app = mount(App, { target: document.getElementById("root")! });
export default app;
`;
const appVue = `<template>
<h1>Hello {{ msg }}</h1>
</template>
<script setup>
import { ref } from 'vue';
const msg = ref('world');
</script>`;
const indexVue = `import { createApp } from 'vue'
import App from './App.vue'
import "./index.css";
createApp(App).mount('#root')`;
const indexCss = `body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -171,21 +156,6 @@ const templates: Record<string, FrameworkTemplate> = {
"svelte": "^5.55.5",
"windmill-client": "^1"
}
}`,
},
},
vue: {
name: "Vue 3",
files: {
"/index.ts": indexVue,
"/App.vue": appVue,
"/index.css": indexCss,
"/package.json": `{
"dependencies": {
"core-js": "3.26.1",
"vue": "3.5.13",
"windmill-client": "^1"
}
}`,
},
},
@@ -251,7 +221,7 @@ interface DataConfig {
schema?: string;
}
type FrameworkKey = "react19" | "react18" | "svelte5" | "vue";
type FrameworkKey = "react19" | "react18" | "svelte5";
interface NewAppOptions extends GlobalOptions {
/** App summary (short description). Skips the prompt when provided. */
@@ -384,7 +354,7 @@ async function newApp(opts: NewAppOptions) {
}
// Ask for framework (skipped if --framework is provided)
const VALID_FRAMEWORKS: FrameworkKey[] = ["react19", "react18", "svelte5", "vue"];
const VALID_FRAMEWORKS: FrameworkKey[] = ["react19", "react18", "svelte5"];
let framework: string;
if (opts.framework !== undefined) {
if (!VALID_FRAMEWORKS.includes(opts.framework)) {
@@ -403,7 +373,6 @@ async function newApp(opts: NewAppOptions) {
{ name: "React 19 (Recommended)", value: "react19" },
{ name: "React 18", value: "react18" },
{ name: "Svelte 5", value: "svelte5" },
{ name: "Vue 3", value: "vue" },
],
});
}
@@ -900,7 +869,7 @@ const command = new Command()
)
.option(
"--framework <framework:string>",
"Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode."
"Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode."
)
.option(
"--datatable <datatable:string>",
+19 -4
View File
@@ -20,13 +20,16 @@ import { deepEqual, readTextFile } from "../../utils/utils.ts";
import {
type AppExecutionMode,
type AppIsolation,
basePolicy,
executionModeForPush,
finalizeDerivedPolicy,
isolationUpToDate,
markAccessFromPolicy,
preserveOnBehalfOfFields,
replaceInlineScripts,
repopulateFields,
statedIsolation,
} from "./app.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { buildPermissionedAsContext } from "../../core/permissioned_as.ts";
@@ -49,6 +52,8 @@ export interface AppFile {
runnables?: any;
custom_path?: string;
public?: boolean;
sandbox?: boolean;
frontend_api?: string[];
summary: string;
policy: Policy;
data?: {
@@ -402,6 +407,7 @@ export async function pushRawApp(
}
const appFilePath = localPath + "raw_app.yaml";
const localApp = (await yamlParseFile(appFilePath)) as AppFile;
const isolation = statedIsolation(localApp, true);
// Load runnables from separate YAML files in the backend folder
// Falls back to reading from raw_app.yaml if no separate files exist (backward compat)
@@ -454,6 +460,7 @@ export async function pushRawApp(
deployedPolicy,
!!preserveFields.preserve_on_behalf_of,
),
isolation,
);
const files = await collectAppFiles(localPath);
@@ -461,7 +468,7 @@ export async function pushRawApp(
log.info(colors.yellow.bold(`Creating raw app ${remotePath} bundle...`));
// Detect frameworks to determine entry point
const frameworks = detectFrameworks(localPath);
const entryFile = frameworks.svelte || frameworks.vue
const entryFile = frameworks.svelte
? "index.ts"
: "index.tsx";
const entryPoint = localPath + entryFile;
@@ -485,14 +492,21 @@ export async function pushRawApp(
// to `<path>.raw_app.json`, not `<path>.app.json`. The backend granular_acls
// handler routes "raw_app" to the `app` table (where v2 raw apps actually
// live) while still dispatching DeployedObject::RawApp for git-sync.
const { extra_perms: localPerms, ...localAppNoPerms } = localApp as AppFile & {
const {
extra_perms: localPerms,
sandbox: _sandbox,
frontend_api: _frontendApi,
...localAppNoPerms
} = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
// Check both metadata/runnables AND files for changes
// Files need separate comparison because isSuperset only checks if local keys exist in remote
const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app);
const metadataUpToDate =
isSuperset({ ...localAppNoPerms, runnables }, app) &&
isolationUpToDate(isolation, deployedPolicy, true);
const filesUpToDate = deepEqual(files, app.value?.files);
if (metadataUpToDate && filesUpToDate) {
log.info(colors.green(`App ${remotePath} is up to date`));
@@ -560,11 +574,12 @@ export async function generatingPolicy(
path: string,
executionMode: AppExecutionMode,
base: Policy | undefined,
isolation?: AppIsolation,
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updateRawAppPolicy(app.runnables, base);
finalizeDerivedPolicy(app.policy, executionMode);
finalizeDerivedPolicy(app.policy, executionMode, isolation, true);
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
+3 -1
View File
@@ -149,7 +149,7 @@ import {
extractCurrentMapping,
} from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { markAccessFromPolicy } from "../app/app.ts";
import { markAccessFromPolicy, markIsolationFromPolicy } from "../app/app.ts";
import {
APP_BACKEND_FOLDER,
generateAppLocksInternal,
@@ -1359,6 +1359,7 @@ export function ZipFSElement(
}
markAccessFromPolicy(app);
markIsolationFromPolicy(app, false);
app.policy = undefined;
yield {
isDirectory: false,
@@ -1377,6 +1378,7 @@ export function ZipFSElement(
throw error;
}
markAccessFromPolicy(rawApp);
markIsolationFromPolicy(rawApp, true);
// console.log("rawApp", rawApp);
rawApp.policy = undefined;
// custom_path is derived from the file path, don't store it
+72 -14
View File
@@ -1106,6 +1106,9 @@ async sleep(seconds: number): Promise<void>
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -1954,6 +1957,9 @@ async sleep(seconds: number): Promise<void>
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -2908,6 +2914,9 @@ async sleep(seconds: number): Promise<void>
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -5091,7 +5100,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a \`\`datetime\`\` comes back as a
@@ -5177,12 +5188,15 @@ def workflow(func)
# \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a \`\`datetime\`\` comes back as a string, a tuple as a list.
#
# Called from inside a task body, \`\`fn\`\` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`.
# Outside a workflow, falls back to \`\`asyncio.sleep\`\`.
# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -5629,6 +5643,27 @@ Name the parameters by adding comments before the statement:
SELECT * FROM users WHERE name = ? AND age > ?;
\`\`\`
## Array parameters
Declare a list with a \`[]\` suffix on the element type (\`(varchar[])\`,
\`(int[])\`), or \`(array)\` for an untyped one. Snowflake cannot bind an array
directly, so Windmill binds the list as JSON text: wrap the placeholder with
\`PARSE_JSON(?)\`, and cast to \`ARRAY\` where a function expects one.
\`\`\`sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY);
\`\`\`
Or join on the elements with \`FLATTEN\`:
\`\`\`sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group;
\`\`\`
## Receiving an S3Object as a script parameter
Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for
@@ -6369,7 +6404,7 @@ You need three values to run the command:
1. **summary** — a short description of the app
2. **path** — the windmill path, e.g. \`f/folder/my_app\` or \`u/username/my_app\`
3. **framework** — one of \`react19\` (recommended), \`react18\`, \`svelte5\`, \`vue\`
3. **framework** — one of \`react19\` (recommended), \`react18\`, \`svelte5\`
**summary and path**: if the user's request did not supply them, ask. Do not guess values and do not invent paths.
@@ -6441,7 +6476,7 @@ my_app{{RAW_APP_SUFFIX}}/
├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh)
├── raw_app.yaml # App configuration (summary, path, data settings)
├── index.tsx # Frontend entry point
├── App.tsx # Main React/Svelte/Vue component
├── App.tsx # Main React/Svelte component
├── index.css # Styles
├── package.json # Frontend dependencies
├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT)
@@ -6599,7 +6634,7 @@ For the rest, tell the user which command fits their intent and let them run it
# Windmill Raw Apps
Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables.
Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.
## App shape
@@ -6615,7 +6650,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t
### Framework
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for.
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.
### Data setup
@@ -6645,7 +6680,7 @@ Then build what was chosen: the data table and schema become the app's \`data.da
### Entrypoint
The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`<div id="root">\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` / \`App.vue\` and keep the entrypoint as the mount shim.
The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`<div id="root">\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` and keep the entrypoint as the mount shim.
React (\`index.tsx\`):
@@ -6657,7 +6692,7 @@ import App from './App'
createRoot(document.getElementById('root')!).render(<App />)
\`\`\`
Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`. Vue (\`index.ts\`): \`createApp(App).mount('#root')\`.
Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`.
**Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`.
@@ -6823,6 +6858,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen
Either one lets those people run the app's backend runnables, so never open an app up unless the user asks.
\`public: true\` in \`raw_app.yaml\` deploys the app for anonymous users, and \`guests: true\` for guests. Remove the line and the next push closes the app again.
## Sandbox isolation and frontend API access
By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls.
In \`raw_app.yaml\`:
\`\`\`yaml
sandbox: true
frontend_api: [jobs:run, users:read]
\`\`\`
A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise.
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
@@ -7455,6 +7502,8 @@ async def enrich(customer_id: str):
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`.
Only the workflow body dispatches steps. A task runs as a single job, so a task or \`step()\` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. \`sleep()\` there waits in the job, and \`taskScript()\` / \`taskFlow()\` / \`waitForApproval()\` (\`task_script()\` / \`task_flow()\` / \`wait_for_approval()\`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them.
For existing scripts:
\`\`\`typescript
@@ -7620,8 +7669,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro
* const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } });
*
* Inside a \`workflow()\`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly and
* {@link TaskOptions} — retry included — does not apply.
* Outside a workflow, or called from inside another task, the function body
* executes directly and {@link TaskOptions} — retry included — does not apply:
* a task runs as a single job, so the tasks it calls run in that same job.
*
* A task runs as its own job, so its result is always encoded as JSON and
* decoded back before the caller sees it: a \`Date\` comes back as a string, a
@@ -7665,6 +7715,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>)
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -7763,7 +7816,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a \`\`datetime\`\` comes back as a
@@ -7849,12 +7904,15 @@ def workflow(func)
# \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a \`\`datetime\`\` comes back as a string, a tuple as a list.
#
# Called from inside a task body, \`\`fn\`\` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`.
# Outside a workflow, falls back to \`\`asyncio.sleep\`\`.
# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -8121,7 +8179,7 @@ app related commands
- \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement
- \`--port <port:number>\` - Port to run the dev server on (will find next available port if occupied)
- \`--host <host:string>\` - Host to bind the dev server to
- \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)
- \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte, index.tsx otherwise)
- \`--no-open\` - Don't automatically open the browser
- \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability
@@ -8132,7 +8190,7 @@ app related commands
- \`app new\` - create a new raw app from a template
- \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
- \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.
- \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode.
- \`--datatable <datatable:string>\` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.
- \`--schema <schema:string>\` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it.
- \`--overwrite\` - Overwrite the target directory if it already exists, without prompting.
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test";
import {
finalizeDerivedPolicy,
isolationUpToDate,
markIsolationFromPolicy,
statedIsolation,
} from "../src/commands/app/app.ts";
function pushed(file: any, deployed: any, rawApp = true) {
const policy = { ...deployed };
finalizeDerivedPolicy(policy, "publisher", statedIsolation(file, rawApp), rawApp);
return policy;
}
test("a pull writes isolation only when it is on, and a push deploys what was pulled", () => {
const off: any = { policy: { sandbox: false, frontend_sdk_scopes: ["jobs:run"] } };
markIsolationFromPolicy(off, true);
expect("sandbox" in off).toBe(false);
expect("frontend_api" in off).toBe(false);
const on: any = { policy: { sandbox: true, frontend_sdk_scopes: ["jobs:run"] } };
markIsolationFromPolicy(on, true);
expect(on.sandbox).toBe(true);
expect(on.frontend_api).toEqual(["jobs:run"]);
// Into a workspace where the app does not exist yet.
const fresh = pushed(on, undefined);
expect(fresh.sandbox).toBe(true);
expect(fresh.frontend_sdk_scopes).toEqual(["jobs:run"]);
});
test("the file wins when it states sandbox, the deployed policy carries over when it does not", () => {
const deployed = { sandbox: true, frontend_sdk_scopes: ["jobs:run"] };
const silent = pushed({}, deployed);
expect(silent.sandbox).toBe(true);
expect(silent.frontend_sdk_scopes).toEqual(["jobs:run"]);
expect(isolationUpToDate(statedIsolation({}, true), deployed, true)).toBe(true);
// A file with the key states the scopes too: none here.
const narrowed = pushed({ sandbox: true }, deployed);
expect(narrowed.sandbox).toBe(true);
expect(narrowed.frontend_sdk_scopes).toBeUndefined();
expect(isolationUpToDate(statedIsolation({ sandbox: true }, true), deployed, true)).toBe(false);
const opened = pushed({ sandbox: false }, deployed);
expect(opened.sandbox).toBe(false);
expect(opened.frontend_sdk_scopes).toBeUndefined();
});
test("scopes are refused without sandbox, outside the allowlist, or repeated", () => {
expect(() => statedIsolation({ frontend_api: ["jobs:run"] }, true)).toThrow("requires 'sandbox: true'");
expect(() => statedIsolation({ sandbox: false, frontend_api: ["jobs:run"] }, true)).toThrow(
"requires 'sandbox: true'",
);
expect(() => statedIsolation({ sandbox: true, frontend_api: ["apps:write"] }, true)).toThrow("Invalid");
expect(() => statedIsolation({ sandbox: true, frontend_api: ["jobs:run", "jobs:run"] }, true)).toThrow(
"Duplicate",
);
expect(() => statedIsolation({ sandbox: true, frontend_api: ["jobs:run"] }, false)).toThrow("full-code");
});
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
// app.ts first: it is the entry the CLI loads lint.ts through, and loading
// lint.ts ahead of it trips their import cycle.
import "../src/commands/app/app.ts";
import { viewerIdentityInputWarnings } from "../src/commands/app/lint.ts";
const sql = (content: string, fields?: Record<string, any>) => ({
type: "inline",
inlineScript: { language: "duckdb", content },
...(fields ? { fields } : {}),
});
test("app lint warns on a SQL viewer-identity argument the server does not fill", async () => {
const content =
"-- $username (text)\n-- $limit (int)\nSELECT * FROM orders WHERE owner = $username LIMIT $limit";
const warnings = await viewerIdentityInputWarnings({
open: sql(content),
filled: sql(content, { username: { ctx: "username" } }),
});
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("Runnable 'open': argument 'username'");
});
+1 -1
View File
@@ -71,7 +71,7 @@ ENV XDG_CACHE_HOME=/pyls/.cache
RUN uv pip install --system --break-system-packages pipenv tornado python-lsp-jsonrpc ruff==0.16.0 Cython
# Install Node-based language servers
RUN npm install -g diagnostic-languageserver pyright
RUN npm install -g pyright
# Setup LSP working directory
WORKDIR /pyls
+55
View File
@@ -121,6 +121,60 @@ async function testLspWebSocket(): Promise<void> {
})
}
async function testLspShellDiagnostics(): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close()
reject(new Error('no shellcheck diagnostics within 10s'))
}, 10000)
const ws = new WebSocket(`ws://${HOST}:${LSP_PORT}/ws/diagnostic`)
const send = (msg: object) => ws.send(JSON.stringify({ jsonrpc: '2.0', ...msg }))
ws.onopen = () => {
// Editors still send a linter definition; the server's own configuration
// applies, so the diagnostics below have to come from shellcheck.
send({
id: 1,
method: 'initialize',
params: {
capabilities: {},
initializationOptions: {
linters: { shellcheck: { command: 'true', sourceName: 'shellcheck' } },
filetypes: { shell: 'shellcheck' }
}
}
})
send({
method: 'textDocument/didOpen',
params: {
textDocument: {
uri: 'file:///tmp/monaco/test.sh',
languageId: 'shell',
version: 1,
text: '#!/bin/bash\necho "$unassigned"\n'
}
}
})
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data.toString())
if (msg.method !== 'textDocument/publishDiagnostics') return
clearTimeout(timeout)
ws.close()
const codes = msg.params.diagnostics.map((d: { code: string }) => d.code)
if (codes.includes('SC2154')) resolve()
else reject(new Error(`expected SC2154, got ${JSON.stringify(msg.params.diagnostics)}`))
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('LSP WebSocket connection failed'))
}
})
}
async function runLspTests(): Promise<boolean> {
log('\n=== LSP Service Tests (Port ' + LSP_PORT + ') ===')
@@ -132,6 +186,7 @@ async function runLspTests(): Promise<boolean> {
let passed = true
passed = (await runTest('LSP health check', testLspHealth)) && passed
passed = (await runTest('LSP WebSocket connection (pyright)', testLspWebSocket)) && passed
passed = (await runTest('LSP shell diagnostics', testLspShellDiagnostics)) && passed
return passed
}
+74 -3
View File
@@ -16,9 +16,22 @@
// tarball. Copy the registry into the package and rewrite those imports
// to point at it (mirrors how package-system-prompts.js bundles
// `$system_prompts`).
//
// 3. Chat SDK: the `windmill-chat` alias (svelte.config.js) points at
// `chat-sdk/src/index.ts`, which escapes the package root the same way.
// Compile that entry point into the package and rewrite those imports.
//
// 4. `$lib/utils` from inside `utils/`: svelte-package turns it into `./`,
// which is the `utils/` directory (no index) rather than `utils.js`.
// Point a directory specifier with no index at its sibling module.
//
// A relative import that still escapes the package root, or an
// `import.meta.glob` left on an alias, fails the build: either one
// publishes fine and only breaks in the consumer's bundler.
import { execFileSync } from 'child_process'
import { copyFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join, relative, resolve } from 'path'
import { dirname, join, relative, resolve, sep } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
@@ -29,8 +42,37 @@ const oauthConnectDest = join(packageDir, 'oauth_connect.json')
copyFileSync(oauthConnectSrc, oauthConnectDest)
const IMPORT_SPECIFIER_RE = /(\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.\.?\/[^'"]*\.(?:svelte|json))\2/g
const chatSdkSrc = resolve(__dirname, '..', '..', 'chat-sdk', 'src', 'index.ts')
const chatSdkDest = join(packageDir, 'chat-sdk')
// File arguments make tsc ignore every tsconfig and emit only what the entry
// point reaches, which leaves out the React and AI SDK adapters.
execFileSync(
'npx',
[
'tsc',
chatSdkSrc,
'--outDir',
chatSdkDest,
'--rootDir',
dirname(chatSdkSrc),
'--declaration',
'--target',
'ES2021',
'--module',
'ESNext',
'--moduleResolution',
'bundler',
'--skipLibCheck'
],
{ stdio: 'inherit', cwd: resolve(__dirname, '..') }
)
const IMPORT_SPECIFIER_RE = /(\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.\.?(?:\/[^'"]*)?)\2/g
const OAUTH_CONNECT_RE = /^(\.\.\/)+backend\/oauth_connect\.json$/
const CHAT_SDK_RE = /^(\.\.\/)+chat-sdk\/src\/index\.ts$/
const DIRECTORY_RE = /(^|\/)\.\.?$|\/$/
const ALIASED_GLOB_RE = /import\.meta\.glob(?:<[^>]*>)?\(\s*\[?\s*['"]\$/
function* walk(dir) {
for (const entry of readdirSync(dir)) {
@@ -50,9 +92,13 @@ function relativeSpecifier(fromFile, toFile) {
let rewrittenFiles = 0
let rewrittenImports = 0
const unresolvable = []
for (const file of walk(packageDir)) {
const content = readFileSync(file, 'utf-8')
if (ALIASED_GLOB_RE.test(content)) {
unresolvable.push(`${relative(packageDir, file)}: import.meta.glob on an alias`)
}
let changed = false
const updated = content.replace(IMPORT_SPECIFIER_RE, (match, prefix, quote, specifier) => {
if (OAUTH_CONNECT_RE.test(specifier)) {
@@ -60,10 +106,27 @@ for (const file of walk(packageDir)) {
rewrittenImports++
return `${prefix}${quote}${relativeSpecifier(file, oauthConnectDest)}${quote}`
}
if (CHAT_SDK_RE.test(specifier)) {
changed = true
rewrittenImports++
return `${prefix}${quote}${relativeSpecifier(file, join(chatSdkDest, 'index.js'))}${quote}`
}
const target = resolve(dirname(file), specifier)
if (target !== packageDir && !target.startsWith(packageDir + sep)) {
unresolvable.push(`${relative(packageDir, file)}: ${specifier}`)
return match
}
if (DIRECTORY_RE.test(specifier)) {
if (!existsSync(join(target, 'index.js')) && existsSync(target + '.js')) {
changed = true
rewrittenImports++
return `${prefix}${quote}${relativeSpecifier(file, target + '.js')}${quote}`
}
return match
}
if (!specifier.endsWith('.svelte')) {
return match
}
const target = resolve(dirname(file), specifier)
// A real `.svelte` component file: leave the import alone.
if (existsSync(target)) {
return match
@@ -82,6 +145,14 @@ for (const file of walk(packageDir)) {
}
}
if (unresolvable.length > 0) {
console.error(
`fix-svelte-module-imports: ${unresolvable.length} import(s) a consumer cannot resolve:\n` +
unresolvable.join('\n')
)
process.exit(1)
}
console.log(
`fix-svelte-module-imports: rewrote ${rewrittenImports} import(s) in ${rewrittenFiles} file(s)`
)
@@ -69,6 +69,8 @@
} from 'lucide-svelte'
import { hubPaths } from '$lib/hub'
import { isCloudHosted } from '$lib/cloud'
import { untrack } from 'svelte'
import { syncSlackBotArg } from './handlerSlackArg'
import SmtpConfigurationStatus from './common/smtp/SmtpConfigurationStatus.svelte'
import { SettingService } from '$lib/gen'
import { isSmtpSettingsValid } from './instanceSettings/SmtpSettings.svelte'
@@ -316,12 +318,13 @@
loadSmtpConfiguration()
})
let builtinArgs: Record<string, any> | undefined
$effect(() => {
if (handlerSelected === 'slack' && isSlackHandler(handlerPath)) {
handlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token'
} else {
handlerExtraArgs['slack'] = undefined
}
const isSlackBuiltin = handlerSelected === 'slack' && isSlackHandler(handlerPath)
const args = handlerExtraArgs
// A replaced args object (Discard, reload) holds the stored config, not what the built-in set.
untrack(() => syncSlackBotArg(args, isSlackBuiltin, args === builtinArgs))
builtinArgs = isSlackBuiltin ? args : undefined
})
let lastHandlerSelected: ErrorHandler | undefined = $state(undefined)
@@ -29,6 +29,7 @@
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import WorkspaceRegistries from './instanceSettings/WorkspaceRegistries.svelte'
import DbHealth from './instanceSettings/DbHealth.svelte'
import OidcSigningStatus from './instanceSettings/OidcSigningStatus.svelte'
interface Props {
tab?: string
@@ -1186,6 +1187,15 @@
description="On-demand database diagnostics. Analyze table sizes, job retention, connection pool health, vacuum status, and more."
/>
<DbHealth />
{:else if category == 'OIDC signing'}
<SettingsPageHeader
title="OIDC signing"
description="What signs the OIDC tokens Windmill issues, and the keys its JWKS serves to verifiers. Read-only: signing is configured with environment variables."
link="https://www.windmill.dev/docs/core_concepts/oidc#signing-key"
/>
{#if $enterpriseLicense}
<OidcSigningStatus />
{/if}
{:else if category == 'Auth/OAuth/SAML'}
<AuthSettings
bind:oauths
@@ -26,7 +26,6 @@
import Toggle from './Toggle.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import DBTable from './DBTable.svelte'
import type { IDbTableOps } from './dbOps'
import { resource } from 'runed'
import { staticDbTableOps } from './staticDbTableOps'
@@ -1160,6 +1159,12 @@
})
</script>
{#snippet tableLoading()}
<div class="flex items-center gap-2 p-3 text-xs text-tertiary">
<Loader2 size={14} class="animate-spin" /> Loading table...
</div>
{/snippet}
{#if workspaceSettingsInitialized === false}
<div class="p-4">
{#if fromWorkspaceSettings}
@@ -1522,17 +1527,20 @@
{#if showTable}
<div class="flex-1 min-h-0 relative">
{#if currentTable?.ops}
{#key currentTable.ops}
<DBTable dbTableOps={currentTable.ops} />
{/key}
<!-- Lazy: the (logged) layout mounts this picker and DBTable reaches monaco. -->
{#await import('./DBTable.svelte')}
{@render tableLoading()}
{:then DBTable}
{#key currentTable.ops}
<DBTable.default dbTableOps={currentTable.ops} />
{/key}
{/await}
{:else if tableOps.error}
<div class="p-3 text-xs text-red-600 dark:text-red-400 break-words">
Could not read this file as a table: {tableOps.error.message}
</div>
{:else}
<div class="flex items-center gap-2 p-3 text-xs text-tertiary">
<Loader2 size={14} class="animate-spin" /> Loading table...
</div>
{@render tableLoading()}
{/if}
</div>
{:else}
@@ -17,7 +17,7 @@
import { truncate } from '$lib/utils'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import {
ArrowRightLeft,
Ban,
@@ -46,6 +46,8 @@
tabToAuthSubTab,
categoryToTabMap,
buildSearchableSettingItems,
listedNavigationGroups,
usesExternalOidcSigner,
type SearchableSettingItem
} from './instanceSettings'
import TextInput from './text_input/TextInput.svelte'
@@ -227,7 +229,14 @@
return instanceSettings?.buildFullDiff() ?? { original: '', modified: '' }
}
// --- Settings search ---
const searchableItems = buildSearchableSettingItems()
let externalOidcSigner = $state(false)
$effect(() => {
if ($enterpriseLicense) usesExternalOidcSigner().then((v) => (externalOidcSigner = v))
})
const navigationGroups = $derived(
listedNavigationGroups(instanceSettingsNavigationGroups, externalOidcSigner)
)
const searchableItems = $derived(buildSearchableSettingItems(navigationGroups))
let scrollTimeout: ReturnType<typeof setTimeout> | undefined
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
@@ -300,11 +309,7 @@
<!-- Sidebar Navigation -->
<div class="w-52 shrink-0 h-full overflow-auto p-4 bg-surface flex flex-col">
<SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" />
<SidebarNavigation
groups={instanceSettingsNavigationGroups}
selectedId={tab}
onNavigate={handleNavigate}
/>
<SidebarNavigation groups={navigationGroups} selectedId={tab} onNavigate={handleNavigate} />
{#if $workspaceStore !== 'admins'}
<div class="mt-4 pt-2 border-t border-surface-hover">
<a
@@ -1306,12 +1306,7 @@ const readDomSchema = z.object({
const takeScreenshotSchema = z.object({})
const FRAMEWORK_KEYS = [
'react19',
'react18',
'svelte5',
'vue'
] as const satisfies readonly FrameworkKey[]
const FRAMEWORK_KEYS = ['react19', 'react18', 'svelte5'] as const satisfies readonly FrameworkKey[]
const initAppSchema = z.object({
path: z
@@ -2493,7 +2488,7 @@ function getAppInstructions(language?: ScriptLang): string {
- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.
- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u/<current-user>/<name>\` for bare names; only use \`f/<folder>/<name>\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.
- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\` / \`vue\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.
- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables.
- Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`.
- Backend inline runnables are addressed as \`backend/<key>/main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`).
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { syncSlackBotArg } from './handlerSlackArg'
const AUTO = '$res:f/slack_bot/bot_token'
describe('syncSlackBotArg', () => {
it("leaves a custom handler's slack value alone", () => {
const args = { slack: '$res:u/me/my_slack' }
syncSlackBotArg(args, false, false)
syncSlackBotArg(args, false, true)
expect(args).toEqual({ slack: '$res:u/me/my_slack' })
})
it('sets the bot token for the built-in and drops it when leaving', () => {
const args: Record<string, any> = {}
syncSlackBotArg(args, true, false)
expect(args.slack).toBe(AUTO)
syncSlackBotArg(args, false, true)
expect(args).toStrictEqual({})
})
})
@@ -0,0 +1,17 @@
import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts'
const AUTO_SLACK_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH
// `slack` belongs to the Slack built-in only while it holds the value the picker set; any other
// value is a custom handler's own argument and must survive handler switches and reloads.
export function syncSlackBotArg(
args: Record<string, any>,
isSlackBuiltin: boolean,
wasSlackBuiltin: boolean
) {
if (isSlackBuiltin) {
args.slack = AUTO_SLACK_ARG
} else if (wasSlackBuiltin && args.slack === AUTO_SLACK_ARG) {
delete args.slack
}
}
@@ -5,6 +5,7 @@ import { instanceBannerFormError } from './instanceBanner'
import { ACCENT_COLOR_SETTING, parseAccentColor } from '$lib/accentColor'
import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration'
import { writable } from 'svelte/store'
import { OidcService } from '$lib/gen'
/**
* Bumped after instance settings are successfully saved. Settings whose display
@@ -830,6 +831,7 @@ export const settings: Record<string, Setting[]> = {
}
],
'DB Health': [],
'OIDC signing': [],
Registries: [
{
label: 'Instance Python Version',
@@ -1344,6 +1346,13 @@ export const instanceSettingsNavigationGroups = [
aiId: 'instance-settings-scim-saml',
aiDescription: 'Instance SCIM/SAML settings',
isEE: true
},
{
id: 'oidc_signing',
label: 'OIDC signing',
aiId: 'instance-settings-oidc-signing',
aiDescription: 'Keys that sign and verify the OIDC tokens Windmill issues',
isEE: true
}
]
},
@@ -1501,6 +1510,7 @@ export const tabToCategoryMap: Record<string, string> = {
github_enterprise_app: 'GitHub App',
websocket: 'WebSocket',
db_health: 'DB Health',
oidc_signing: 'OIDC signing',
lsp: 'LSP'
}
@@ -1510,6 +1520,30 @@ export const tabToAuthSubTab: Record<string, 'sso' | 'oauth' | 'scim'> = {
scim_saml: 'scim'
}
/**
* "OIDC signing" is listed only on instances that sign with an external signer: its page
* says nothing actionable elsewhere.
*/
export function listedNavigationGroups<G extends { items: { id: string }[] }>(
groups: G[],
externalOidcSigner: boolean
): G[] {
if (externalOidcSigner) return groups
return groups.map((group) => ({
...group,
items: group.items.filter((item) => item.id !== 'oidc_signing')
}))
}
/** The status endpoint is EE only: call it once the license is known. */
export async function usesExternalOidcSigner(): Promise<boolean> {
try {
return (await OidcService.getOidcSigningStatus()).mode === 'external'
} catch {
return false
}
}
// Navigation groups for the initial setup flow (no Users tab)
export const setupNavigationGroups = instanceSettingsNavigationGroups
.map((group) => ({
@@ -1538,6 +1572,7 @@ export const categoryToTabMap: Record<string, string> = {
'GitHub App': 'github_enterprise_app',
WebSocket: 'websocket',
'DB Health': 'db_health',
'OIDC signing': 'oidc_signing',
LSP: 'lsp'
}
@@ -0,0 +1,100 @@
<script lang="ts">
import { OidcService } from '$lib/gen'
import { Alert, Badge, CopyButton } from '$lib/components/common'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import { displayDate } from '$lib/utils'
type Status = Awaited<ReturnType<typeof OidcService.getOidcSigningStatus>>
let status = $state<Status | undefined>(undefined)
let loadError = $state<string | undefined>(undefined)
const modeLabels: Record<Status['mode'], string> = {
database: 'Key stored in the database',
file: 'Key file (OIDC_SIGNING_KEY_FILE)',
external: 'External signer (OIDC_SIGNER_URL)'
}
$effect(() => {
OidcService.getOidcSigningStatus()
.then((s) => {
status = s
loadError = undefined
})
.catch((e) => {
loadError = e?.body ?? e?.message ?? String(e)
})
})
</script>
{#if loadError}
<Alert type="error" title="Could not load the OIDC signing status">{loadError}</Alert>
{:else if status}
<div class="flex flex-col gap-4">
<div class="text-xs text-primary">
<span class="text-secondary">Tokens are signed by:</span>
{modeLabels[status.mode]}
</div>
{#if status.signer}
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
<span class="text-secondary">Last key listing:</span>
{status.signer.last_refresh_success
? displayDate(status.signer.last_refresh_success)
: 'never'}
{#if status.signer.breaker !== 'closed'}
<Badge color="red">Signer unreachable</Badge>
{:else if !status.signer.last_refresh_error && status.signer.last_refresh_success}
<Badge color="green">Signer reachable</Badge>
{/if}
</div>
{#if status.signer.last_refresh_error}
<Alert type="warning" title="The last key listing was refused">
{status.signer.last_refresh_error}
</Alert>
{/if}
{#if status.signer.keys_from_store}
<Alert type="warning" title="The signer has not answered since this server started">
The keys below are those stored by the last accepted listing. Tokens cannot be signed
until the signer answers.
</Alert>
{/if}
{#if status.signer.tls_files_error}
<Alert type="warning" title="The mTLS files on disk are not in use">
{status.signer.tls_files_error}. The previous certificate and key are kept.
</Alert>
{/if}
{/if}
<DataTable size="xs">
<Head>
<tr>
<Cell head first>Key ID (kid)</Cell>
<Cell head>Algorithm</Cell>
<Cell head>State</Cell>
<Cell head last>Source</Cell>
</tr>
</Head>
<tbody>
{#each status.keys as key, i (`${key.source}:${key.kid ?? i}`)}
<tr>
<Cell first>
<span class="flex items-center gap-1 font-mono text-xs">
{key.kid}
{#if key.kid}<CopyButton value={key.kid} />{/if}
</span>
</Cell>
<Cell>{key.alg}</Cell>
<Cell>
<Badge color={key.state === 'active' ? 'green' : 'gray'}>{key.state}</Badge>
</Cell>
<Cell last>{key.source}</Cell>
</tr>
{/each}
</tbody>
</DataTable>
{#if status.keys.length === 0}
<div class="text-xs text-secondary">The JWKS serves no key.</div>
{/if}
</div>
{/if}
@@ -12,8 +12,11 @@ import { findMcpEntry, findMcpEntryByUrl } from './registry'
* the chat does not otherwise reach that barrel. `import.meta.glob` gives the
* filenames at build time and the module only when a match is actually used, so
* a workspace with two connections downloads two icons.
*
* The pattern is relative because svelte-package rewrites `$lib` only in import
* specifiers: an aliased glob ships as-is and no consumer's bundler expands it.
*/
const iconModules = import.meta.glob('$lib/components/icons/*.svelte') as Record<
const iconModules = import.meta.glob('../icons/*.svelte') as Record<
string,
() => Promise<{ default: Component<any> }>
>
@@ -21,6 +21,7 @@
genWmillTs,
normalizeRawAppRuntimeLogs,
createRawAppBuildTracker,
RAW_APP_BUILD_WAIT_MS,
type Runnable,
type RawAppRuntimeLogEntry,
type RawAppRuntimeLogRequester,
@@ -488,6 +489,17 @@ addEventListener('message', function (e) {
// Latest UI Builder error; cleared on next successful build.
let buildError = $state<string | undefined>(undefined)
const buildTracker = createRawAppBuildTracker()
// The preview shell can be ready before the UI Builder has the files, so a fresh
// editor counts as building: reads made then would see an empty page.
buildTracker.start()
const BUILD_PENDING_TEXT = `The app is still building (or its build never started, e.g. no index entrypoint file): no build result arrived within ${RAW_APP_BUILD_WAIT_MS / 1000} seconds, so the preview shows an older build or nothing yet. Call get_app_runtime_logs to check the build, then retry.`
// A failed build never reaches the preview, which keeps the last good one, if any.
const buildFailedText = (error: string) =>
`The latest build failed, so the preview does not include the latest edits (it shows the last build that succeeded, or nothing if none has). Fix the build first. Build error:\n${error}`
const SDK_CONSENT_PENDING_TEXT =
'The preview is waiting for the user to approve the permissions the app declares in policy.frontend_sdk_scopes, so the app code is not running and the page is empty. This is not a bug in the app: do not change the code. Ask the user to approve the permissions in the preview, then retry.'
const SDK_STARTING_TEXT =
'The preview is still loading the permissions the app declares in policy.frontend_sdk_scopes, so the app code has not started yet. Retry in a moment.'
const PREVIEW_SETTLE_MS = 1000
let editorDestroyed = false
// Latest uncaught runtime error thrown by the rendered app; cleared on next build.
@@ -496,7 +508,7 @@ addEventListener('message', function (e) {
// entrypoint defines a component without ever mounting it. Cleared on next build.
let emptyRender = $state(false)
// The repair hint above has to match the app's framework: only React apps have
// an `index.tsx` and `createRoot`; Svelte and Vue mount from `index.ts`. Keyed
// an `index.tsx` and `createRoot`; Svelte mounts from `index.ts`. Keyed
// off file extensions rather than exact template filenames, which users rename.
let mountHint = $derived.by(() => {
const paths = Object.keys(files ?? {}).map((p) => p.replace(/^\//, ''))
@@ -507,9 +519,6 @@ addEventListener('message', function (e) {
call: "mount(App, { target: document.getElementById('root')! })"
}
}
if (paths.some((p) => p.endsWith('.vue'))) {
return { entrypoint: entrypoint ?? 'index.ts', call: "createApp(App).mount('#root')" }
}
return {
entrypoint: entrypoint ?? 'index.tsx',
call: "createRoot(document.getElementById('root')!).render(<App />)"
@@ -1979,6 +1988,18 @@ addEventListener('message', function (e) {
// Live DOM inspection for the session chat. The element is re-read on every call,
// so the model always sees the current render.
const requestDomQuery: RawAppDomRequester = async (query: RawAppDomQuery) => {
await buildTracker.wait()
if (editorDestroyed) return undefined
if (buildTracker.pending) return { text: BUILD_PENDING_TEXT }
if (sdkPrompt) return { text: SDK_CONSENT_PENDING_TEXT }
if (previewSdkPending) return { text: SDK_STARTING_TEXT }
const res = await readPreviewDom(query)
return res && buildError !== undefined
? { text: `${buildFailedText(buildError)}\n\n${res.text}` }
: res
}
const readPreviewDom: RawAppDomRequester = async (query: RawAppDomQuery) => {
const selector = query.selector?.trim()
const res = await requestFromPreview('domRead', { selector })
if (!res) return undefined
@@ -2145,6 +2166,12 @@ addEventListener('message', function (e) {
// reconstructs the DOM into an SVG foreignObject, and a WebGL canvas is only
// captured when its context was created with preserveDrawingBuffer.
const captureScreenshot: RawAppScreenshotRequester = async () => {
await buildTracker.wait()
if (editorDestroyed) throw new Error('The app preview was closed')
if (buildTracker.pending) throw new Error(BUILD_PENDING_TEXT)
if (sdkPrompt) throw new Error(SDK_CONSENT_PENDING_TEXT)
if (previewSdkPending) throw new Error(SDK_STARTING_TEXT)
if (buildError !== undefined) throw new Error(buildFailedText(buildError))
const body = await requestFromPreview('elementRect', { selector: 'body' })
if (!body?.rect) throw new Error('App preview is not ready')
const { width, height } = body.rect
@@ -15,6 +15,7 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import Tabs from '../common/tabs/Tabs.svelte'
import { Tab } from '../common'
import { SQL_LANGUAGES } from '$lib/infer'
import RawAppInputsSpecEditor from './RawAppInputsSpecEditor.svelte'
import SplitPanesWrapper from '../splitPanes/SplitPanesWrapper.svelte'
import SchemaForm from '../SchemaForm.svelte'
@@ -80,6 +81,13 @@
}
}
// A SQL argument named after a viewer property is most often a per-viewer filter,
// which only a ctx field protects. `wmill app lint` warns on the same names.
const VIEWER_IDENTITY_ARGS = ['username', 'email', 'groups']
let isSql = $derived(
isRunnableByName(runnable) && SQL_LANGUAGES.includes(runnable.inlineScript?.language ?? '')
)
let selectedTab = $state('test')
let args = $state({})
let hubFlowPreview: OpenFlow | undefined = $state(undefined)
@@ -266,6 +274,7 @@
documentationLink={meta?.['documentationLink']}
allowTypeChange={meta?.['allowTypeChange']}
displayType
suggestCtx={isSql && VIEWER_IDENTITY_ARGS.includes(k)}
/>
{/each}
</div>
@@ -13,6 +13,7 @@
import { fieldTypeToTsType } from '../apps/utils'
import type { InputType } from '../apps/inputType'
import Select from '$lib/components/select/Select.svelte'
import { Alert } from '$lib/components/common'
import { userStore } from '$lib/stores'
import {
useOperatingUser,
@@ -67,6 +68,8 @@
allowTypeChange?: boolean
loading?: boolean
documentationLink?: string | undefined
/** Warn that this input, left as a frontend input, is open to any value a viewer sends. */
suggestCtx?: boolean
}
let {
@@ -85,7 +88,8 @@
displayType = false,
allowTypeChange = true,
loading = false,
documentationLink = undefined
documentationLink = undefined,
suggestCtx = false
}: Props = $props()
run(() => {
@@ -193,6 +197,11 @@
</div>
{:else if componentInput?.type === 'user' || componentInput?.type == undefined}
<span class="text-2xs italic text-primary">Field's value is a frontend input</span>
{#if suggestCtx}
<Alert type="warning" size="xs" title="A viewer can send any value">
If this identifies the viewer, switch it to context so the server fills it.
</Alert>
{/if}
{/if}
{#if componentInput?.type === 'user' && ((fieldType == 'object' && format?.startsWith('resource-') && format !== 'resource-s3_object') || fieldType == 'resource')}
<div class="flex flex-row items-center">
@@ -1,5 +1,5 @@
// The starter app is a tour of what a raw app can do: each card calls one of the
// STARTER_RUNNABLES below through a different `./wmill` export. The three framework
// STARTER_RUNNABLES below through a different `./wmill` export. The framework
// variants share `indexCss` and must stay in step with each other and with the runnables.
const reactIndex = `
@@ -13,7 +13,7 @@ root.render(<App/>);
`
const STATS_SAMPLE_TEXT =
'Windmill turns scripts into apps. Write the frontend in React, Svelte or Vue, and the backend in TypeScript, Python or any other supported language. Scripts run on workers, and the app calls them like functions.'
'Windmill turns scripts into apps. Write the frontend in React or Svelte, and the backend in TypeScript, Python or any other supported language. Scripts run on workers, and the app calls them like functions.'
const appTsx = `import React, { useState } from 'react'
import { backend, backendAsync, getJob, streamJob } from './wmill'
@@ -516,230 +516,6 @@ const app = mount(App, { target: document.getElementById("root")! });
export default app;
`
const appVue = `<template>
<main class="app">
<header class="hero">
<span class="eyebrow">Windmill app{{ ctx.workspace ? ' · ' + ctx.workspace : '' }}</span>
<h1>{{ firstName ? 'Welcome, ' + firstName : 'Welcome' }}</h1>
<p class="muted">
A Vue frontend wired to backend runnables. Each card shows one thing an app can do.
Edit App.vue and the runnables to make it yours, or ask the AI to build on it.
</p>
</header>
<div class="grid">
<section class="card">
<div class="card-header">
<span class="card-step">1</span>
<div>
<h2>Call a backend runnable</h2>
<p class="muted">Runnables are scripts that run on Windmill workers. Call them like async functions; context fields add who is calling.</p>
</div>
</div>
<div class="card-body">
<form class="row" @submit.prevent="greet">
<input v-model="name" placeholder="Your name" />
<button type="submit" :disabled="greetLoading">{{ greetLoading ? 'Running...' : 'Run' }}</button>
</form>
<div class="output">
<span v-if="greetError" class="error">{{ greetError }}</span>
<template v-else-if="greeting">
<strong>{{ greeting.message }}</strong>
<span class="muted"> Ran at {{ new Date(greeting.ran_at).toLocaleTimeString() }}</span>
</template>
<span v-else class="muted">The result shows up here.</span>
</div>
</div>
<pre class="snippet"><code>const res = await backend.greet({ name })</code></pre>
</section>
<section class="card">
<div class="card-header">
<span class="card-step">2</span>
<div>
<h2>Use any language</h2>
<p class="muted">Each runnable picks its own language. This one is Python, called the same way.</p>
</div>
</div>
<div class="card-body">
<textarea rows="4" v-model="text"></textarea>
<div class="row">
<button @click="analyze" :disabled="statsLoading">
{{ statsLoading ? 'Analyzing...' : 'Analyze with Python' }}
</button>
</div>
<div v-if="statsError" class="output error">{{ statsError }}</div>
<div v-if="stats" class="stats">
<div><strong>{{ stats.words }}</strong><span class="muted">words</span></div>
<div><strong>{{ stats.sentences }}</strong><span class="muted">sentences</span></div>
<div><strong>{{ stats.characters }}</strong><span class="muted">characters</span></div>
<div class="tags">
<span v-for="word in stats.top_words" :key="word" class="tag">{{ word }}</span>
</div>
</div>
</div>
<pre class="snippet"><code>const stats = await backend.word_stats({ text })</code></pre>
</section>
<section class="card">
<div class="card-header">
<span class="card-step">3</span>
<div>
<h2>Run jobs in the background</h2>
<p class="muted">Start long jobs without blocking the UI, then poll their status. Start several at once.</p>
</div>
</div>
<div class="card-body">
<div class="row">
<button @click="startJob">Start a job</button>
</div>
<div v-if="jobsError" class="output error">{{ jobsError }}</div>
<ul class="jobs">
<li v-if="jobs.length === 0" class="muted">No jobs yet.</li>
<li v-for="job in jobs" :key="job.id">
<span :class="['pill', job.status]">{{ job.status }}</span>
<code :title="job.id">{{ job.id.slice(-8) }}</code>
<span class="muted">{{ job.seconds }}s</span>
<div class="bar">
<div :class="['bar-fill', job.status]" :style="{ animationDuration: job.seconds + 's' }"></div>
</div>
</li>
</ul>
</div>
<pre class="snippet"><code>const id = await backendAsync.slow_task({ seconds })
const job = await getJob(id)</code></pre>
</section>
<section class="card">
<div class="card-header">
<span class="card-step">4</span>
<div>
<h2>Stream results</h2>
<p class="muted">A runnable can yield its output bit by bit. Render it as it arrives, like an LLM answer.</p>
</div>
</div>
<div class="card-body">
<div class="row">
<button @click="stream" :disabled="streaming">{{ streaming ? 'Streaming...' : 'Start streaming' }}</button>
</div>
<div class="output stream">
<span v-if="streamError" class="error">{{ streamError }}</span>
<template v-else-if="streamed || streaming">
{{ streamed }}<span v-if="streaming" class="caret"></span>
</template>
<span v-else class="muted">Streamed text shows up here.</span>
</div>
</div>
<pre class="snippet"><code>await streamJob(id, (u) => append(u.new_result_stream))</code></pre>
</section>
</div>
</main>
</template>
<script setup>
import { ref } from 'vue'
import { backend, backendAsync, getJob, streamJob } from './wmill'
// Windmill injects the viewer (absent for anonymous visitors) and the workspace.
const ctx = window.ctx ?? {}
const firstName = (ctx.ctx?.name || ctx.ctx?.username || '').split(' ')[0]
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
function errorMessage(e) {
return e?.message ?? (typeof e === 'string' ? e : JSON.stringify(e))
}
// 1. Call a backend runnable
const name = ref(firstName || 'world')
const greeting = ref()
const greetError = ref()
const greetLoading = ref(false)
async function greet() {
greetLoading.value = true
greetError.value = undefined
try {
greeting.value = await backend.greet({ name: name.value })
} catch (e) {
greetError.value = errorMessage(e)
}
greetLoading.value = false
}
// 2. Use any language
const text = ref(${JSON.stringify(STATS_SAMPLE_TEXT)})
const stats = ref()
const statsError = ref()
const statsLoading = ref(false)
async function analyze() {
statsLoading.value = true
statsError.value = undefined
try {
stats.value = await backend.word_stats({ text: text.value })
} catch (e) {
statsError.value = errorMessage(e)
}
statsLoading.value = false
}
// 3. Run jobs in the background
const jobs = ref([])
const jobsError = ref()
function setStatus(id, status) {
const row = jobs.value.find((r) => r.id === id)
if (row) row.status = status
}
async function startJob() {
jobsError.value = undefined
try {
const seconds = 2 + Math.floor(Math.random() * 4)
const id = await backendAsync.slow_task({ seconds })
jobs.value = [{ id, seconds, status: 'queued' }, ...jobs.value].slice(0, 4)
while (true) {
await sleep(500)
const job = await getJob(id)
if (job.type === 'CompletedJob') {
setStatus(id, job.success ? 'success' : 'failure')
return
}
setStatus(id, job.started_at ? 'running' : 'queued')
}
} catch (e) {
jobsError.value = errorMessage(e)
}
}
// 4. Stream results
const streamed = ref('')
const streamError = ref()
const streaming = ref(false)
async function stream() {
streamed.value = ''
streamError.value = undefined
streaming.value = true
try {
const id = await backendAsync.stream_text({})
await streamJob(id, (update) => {
if (update.new_result_stream) streamed.value += update.new_result_stream
})
} catch (e) {
streamError.value = errorMessage(e)
}
streaming.value = false
}
</script>`
const indexVue = `import { createApp } from 'vue'
import App from './App.vue'
import "./index.css";
createApp(App).mount('#root')`
const indexCss = `/* The app renders inside an iframe whose host page has its own theme the
iframe cannot read, so paint an opaque surface here: without one the host's
background shows through and dark text lands on it unreadable. */
@@ -1158,23 +934,10 @@ export const svelte5Template = {
}`
}
export const vueTemplate = {
'/index.ts': indexVue,
'/App.vue': appVue,
'/index.css': indexCss,
'/package.json': `{
"dependencies": {
"core-js": "3.26.1",
"vue": "3.5.13"
}
}`
}
export const FRAMEWORK_TEMPLATES = {
react19: react19Template,
react18: react18Template,
svelte5: svelte5Template,
vue: vueTemplate
svelte5: svelte5Template
}
export type FrameworkKey = keyof typeof FRAMEWORK_TEMPLATES
@@ -1551,171 +1314,6 @@ export async function main() {
}
}
export const appVueRouter = `
<template>
<div class="container">
<!-- Navigation tabs -->
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="{ active: currentTab === tab.id }"
@click="changeTab(tab.id)"
>
{{ tab.name }}
</button>
</nav>
<!-- Content sections -->
<div class="content">
<div v-if="currentTab === 'home'" class="tab-content">
<h2>Home</h2>
<p>Welcome to the home tab!</p>
<!-- Nested navigation example -->
<div class="sub-nav">
<button
v-for="subItem in ['latest', 'popular']"
:key="subItem"
:class="{ active: currentSort === subItem }"
@click="changeSort(subItem)"
>
{{ subItem }}
</button>
</div>
</div>
<div v-else-if="currentTab === 'about'" class="tab-content">
<h2>About</h2>
<p>This is the about section</p>
</div>
<div v-else-if="currentTab === 'contact'" class="tab-content">
<h2>Contact</h2>
<p>Contact information here</p>
</div>
</div>
<!-- Debug info -->
<div class="debug">
<p>Current Tab: {{ currentTab }}</p>
<p>Current Sort: {{ currentSort }}</p>
<p>Current Hash: {{ currentHash }}</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
// Available tabs
const tabs = [
{ id: 'home', name: 'Home' },
{ id: 'about', name: 'About' },
{ id: 'contact', name: 'Contact' }
];
const currentTab = ref('home');
const currentSort = ref('latest');
const currentHash = ref('');
// Navigation functions
function changeTab(tabId) {
currentTab.value = tabId;
updateHash();
}
function changeSort(sort) {
currentSort.value = sort;
updateHash();
}
function updateHash() {
const params = new URLSearchParams();
params.set('tab', currentTab.value);
if (currentSort.value !== 'latest') {
params.set('sort', currentSort.value);
}
window.location.hash = params.toString();
currentHash.value = window.location.hash;
}
function parseHash() {
const params = new URLSearchParams(window.location.hash.slice(1));
currentTab.value = params.get('tab') || 'home';
currentSort.value = params.get('sort') || 'latest';
currentHash.value = window.location.hash;
}
// Hash change handler
function handleHashChange() {
parseHash();
}
onMounted(() => {
if (window.location.hash) {
parseHash();
} else {
updateHash();
}
window.addEventListener('hashchange', handleHashChange);
});
onUnmounted(() => {
window.removeEventListener('hashchange', handleHashChange);
});
</script>
<style scoped>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
button {
padding: 8px 16px;
border: 1px solid #ddd;
background: #fff;
border-radius: 4px;
cursor: pointer;
}
button.active {
background: #4CAF50;
color: white;
border-color: #4CAF50;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-radius: 4px;
}
.tab-content {
margin-bottom: 20px;
}
.sub-nav {
margin-top: 10px;
}
.debug {
margin-top: 20px;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
font-size: 0.9em;
color: #666;
}
</style>`
export const appSvelteRouter = `
<script>
import { onMount, onDestroy } from 'svelte';
+1 -1
View File
@@ -301,7 +301,7 @@ export async function inferAnsibleExecutionMode(code: string): Promise<any> {
}
}
const SQL_LANGUAGES = [
export const SQL_LANGUAGES = [
'postgresql',
'mysql',
'bigquery',
@@ -11,12 +11,14 @@
tabToAuthSubTab,
categoryToTabMap,
buildSearchableSettingItems,
listedNavigationGroups,
usesExternalOidcSigner,
type SearchableSettingItem
} from '$lib/components/instanceSettings'
import SettingsSearchInput from '$lib/components/instanceSettings/SettingsSearchInput.svelte'
import Breadcrumb from '$lib/components/common/breadcrumb/Breadcrumb.svelte'
import { ChevronRight, ArrowLeft } from 'lucide-svelte'
import { superadmin } from '$lib/stores'
import { enterpriseLicense, superadmin } from '$lib/stores'
import { onDestroy, tick } from 'svelte'
import { SettingService, UserService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
@@ -178,7 +180,14 @@
}
// --- Settings search (full mode) ---
const searchableItems = buildSearchableSettingItems(setupNavigationGroups)
let externalOidcSigner = $state(false)
$effect(() => {
if ($enterpriseLicense) usesExternalOidcSigner().then((v) => (externalOidcSigner = v))
})
const navigationGroups = $derived(
listedNavigationGroups(setupNavigationGroups, externalOidcSigner)
)
const searchableItems = $derived(buildSearchableSettingItems(navigationGroups))
let scrollTimeout: ReturnType<typeof setTimeout> | undefined
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
@@ -487,7 +496,7 @@
<div class="w-44 shrink-0 h-full overflow-auto pb-4 pr-4">
<SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" />
<SidebarNavigation
groups={setupNavigationGroups}
groups={navigationGroups}
selectedId={fullTab}
onNavigate={handleNavigate}
/>
@@ -1133,10 +1133,14 @@
}
// Strip keys from extraArgs that are auto-managed by child components:
// - 'slack': computed by ErrorOrRecoveryHandler's $effect based on handler type
// - 'slack' while the Slack built-in is selected: set by ErrorOrRecoveryHandler's $effect
// - 'channel_name': display metadata stripped by SchemaForm's removeExtraKey()
function normalizeHandlerExtraArgs(args: Record<string, any>): Record<string, any> {
const { slack: _, channel_name: __, ...rest } = args
function normalizeHandlerExtraArgs(
args: Record<string, any>,
handlerSelected: ErrorHandler
): Record<string, any> {
const { channel_name: _, ...rest } = args
if (handlerSelected === 'slack') delete rest.slack
return rest
}
@@ -1146,7 +1150,10 @@
errorHandlerSelected: initialErrorHandlerSelected,
errorHandlerScriptPath: initialErrorHandlerScriptPath,
errorHandlerItemKind: initialErrorHandlerItemKind,
errorHandlerExtraArgs: normalizeHandlerExtraArgs(initialErrorHandlerExtraArgs),
errorHandlerExtraArgs: normalizeHandlerExtraArgs(
initialErrorHandlerExtraArgs,
initialErrorHandlerSelected
),
errorHandlerMutedOnCancel: initialErrorHandlerMutedOnCancel,
errorHandlerMutedOnUserPath: initialErrorHandlerMutedOnUserPath
}
@@ -1155,7 +1162,7 @@
errorHandlerSelected: errorHandlerSelected,
errorHandlerScriptPath: errorHandlerScriptPath,
errorHandlerItemKind: errorHandlerItemKind,
errorHandlerExtraArgs: normalizeHandlerExtraArgs(errorHandlerExtraArgs),
errorHandlerExtraArgs: normalizeHandlerExtraArgs(errorHandlerExtraArgs, errorHandlerSelected),
errorHandlerMutedOnCancel: errorHandlerMutedOnCancel,
errorHandlerMutedOnUserPath: errorHandlerMutedOnUserPath
}
+222 -3
View File
@@ -1,6 +1,9 @@
import asyncio
import logging
import shutil
import subprocess
import threading
import time
import os
import urllib.request
import urllib.error
@@ -149,8 +152,221 @@ class LanguageServerWebSocketHandler(websocket.WebSocketHandler):
class PyrightLS(LanguageServerWebSocketHandler):
procargs = ["pipenv", "run", "pyright-langserver", "--stdio"]
class DiagnosticLS(LanguageServerWebSocketHandler):
procargs = ["diagnostic-languageserver", "--stdio", "--log-level", "4"]
SHELLCHECK_BIN = shutil.which("shellcheck")
# The linter is configured here rather than by the editor's initializationOptions.
SHELLCHECK_ARGS = ["--norc", "--format=json1", "-"]
SHELLCHECK_TIMEOUT_SECS = float(os.environ.get("SHELLCHECK_TIMEOUT_SECS", "10"))
SHELLCHECK_MAX_DOC_CHARS = int(os.environ.get("SHELLCHECK_MAX_DOC_CHARS", "200000"))
SHELLCHECK_MAX_DOCS_PER_CONN = 8
# A document whose checks finish faster than this is checked on every change
# with no pause; see ShellcheckLS._changed.
SHELLCHECK_INSTANT_SECS = 0.05
SHELLCHECK_MAX_DEBOUNCE_SECS = 1.0
# Shared by every connection: shellcheck is CPU-bound and single-threaded, so
# more concurrent runs than cores only makes all of them slower.
_shellcheck_slots = asyncio.Semaphore(os.cpu_count() or 2)
_SHELLCHECK_SEVERITIES = {"error": 1, "warning": 2, "info": 3, "style": 3}
def _utf16_col(line, col):
"""shellcheck counts code points, LSP positions count UTF-16 code units."""
col = max(col - 1, 0)
if line.isascii():
return col
return len(line[:col].encode("utf-16-le")) // 2
def shellcheck_diagnostics(text, output):
lines = text.split("\n")
def position(line, col):
line = max(line - 1, 0)
return {
"line": line,
"character": _utf16_col(lines[line], col) if line < len(lines) else 0,
}
diagnostics = []
for c in json.loads(output).get("comments", []):
code = "SC%d" % c["code"]
diagnostics.append(
{
"range": {
"start": position(c["line"], c["column"]),
"end": position(c["endLine"], c["endColumn"]),
},
"severity": _SHELLCHECK_SEVERITIES.get(c["level"], 3),
"code": code,
"codeDescription": {"href": "https://www.shellcheck.net/wiki/" + code},
"source": "shellcheck",
"message": c["message"],
}
)
return diagnostics
class ShellcheckLS(websocket.WebSocketHandler):
"""Lints shell documents by running shellcheck itself, speaking only the
slice of LSP that takes: document sync in, diagnostics out."""
def open(self):
self.docs = {}
self.tasks = {}
self.cost = {}
self.linted = {}
def check_origin(self, origin):
return True
def on_message(self, message):
try:
msg = json.loads(message)
method = msg.get("method")
params = msg.get("params") or {}
if method == "initialize":
self._send(
{
"jsonrpc": "2.0",
"id": msg.get("id"),
"result": {
"capabilities": {
"textDocumentSync": {"openClose": True, "change": 1}
},
"serverInfo": {"name": "windmill-shellcheck"},
},
}
)
elif method == "textDocument/didOpen":
doc = params["textDocument"]
self._changed(doc["uri"], doc.get("version"), doc["text"])
elif method == "textDocument/didChange":
doc = params["textDocument"]
self._changed(
doc["uri"], doc.get("version"), params["contentChanges"][-1]["text"]
)
elif method == "textDocument/didClose":
self._forget(params["textDocument"]["uri"])
elif method == "shutdown":
self._send({"jsonrpc": "2.0", "id": msg.get("id"), "result": None})
elif method is not None and "id" in msg:
self._send(
{
"jsonrpc": "2.0",
"id": msg["id"],
"error": {"code": -32601, "message": "Method not found"},
}
)
except (ValueError, KeyError, IndexError, TypeError, AttributeError):
log.warning("shellcheck: dropping malformed message")
def on_close(self):
for uri in list(self.docs):
self._forget(uri)
def _send(self, msg):
try:
self.write_message(json.dumps(msg))
except websocket.WebSocketClosedError:
pass
def _forget(self, uri):
task = self.tasks.pop(uri, None)
if task:
task.cancel()
self.docs.pop(uri, None)
self.cost.pop(uri, None)
self.linted.pop(uri, None)
def _changed(self, uri, version, text):
if not isinstance(uri, str) or not isinstance(text, str):
raise TypeError
if uri not in self.docs and len(self.docs) >= SHELLCHECK_MAX_DOCS_PER_CONN:
return
if len(text) > SHELLCHECK_MAX_DOC_CHARS:
# Not checked, so not kept either: reported as having no findings.
text = None
self.docs[uri] = (version, text)
if uri in self.linted and self.linted[uri] == text:
return
running = self.tasks.pop(uri, None)
# The pause before a check is what that document's checks cost: one that
# is cheap is checked at once, one that is not waits out a typing burst
# rather than burning a core on results the next keystroke discards.
delay = self.cost.get(uri, 0)
if delay < SHELLCHECK_INSTANT_SECS:
delay = 0
if running and not running.done():
running.cancel()
delay = max(delay, SHELLCHECK_INSTANT_SECS)
self.tasks[uri] = asyncio.ensure_future(self._lint(uri, version, text, delay))
async def _lint(self, uri, version, text, delay):
if delay:
await asyncio.sleep(delay)
diagnostics = []
if SHELLCHECK_BIN and text is not None:
async with _shellcheck_slots:
started = time.monotonic()
try:
output = await self._run(text)
self.cost[uri] = 0
finally:
# A killed check counts too, as a lower bound: it is the only
# measure a document edited faster than it can be checked gets.
if uri in self.docs:
self.cost[uri] = min(
max(time.monotonic() - started, self.cost.get(uri, 0)),
SHELLCHECK_MAX_DEBOUNCE_SECS,
)
if output is None:
return
try:
diagnostics = shellcheck_diagnostics(text, output)
except (ValueError, KeyError, TypeError) as e:
log.warning("shellcheck: unreadable output: %s", e)
return
if self.docs.get(uri) != (version, text):
return
self.linted[uri] = text
params = {"uri": uri, "diagnostics": diagnostics}
if version is not None:
params["version"] = version
self._send(
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": params,
}
)
async def _run(self, text):
try:
proc = await asyncio.create_subprocess_exec(
SHELLCHECK_BIN,
*SHELLCHECK_ARGS,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except OSError as e:
log.warning("shellcheck: could not start: %s", e)
return None
try:
output, _ = await asyncio.wait_for(
proc.communicate(text.encode("utf-8", "replace")),
SHELLCHECK_TIMEOUT_SECS,
)
return output
except asyncio.TimeoutError:
log.warning("shellcheck: timed out on a %d char document", len(text))
return None
finally:
# Also reached when a newer change cancels this check mid-run.
if proc.returncode is None:
proc.kill()
await proc.wait()
class RuffLS(LanguageServerWebSocketHandler):
procargs = ["ruff", "server"]
@@ -201,11 +417,14 @@ if __name__ == "__main__":
# spawned `ruff server` picks it up.
start_ruff_config_poller()
if not SHELLCHECK_BIN:
log.warning("shellcheck is not on PATH: shell scripts will not be linted")
port = int(os.environ.get("PORT", "3001"))
app = web.Application(
[
(r"/ws/pyright", PyrightLS),
(r"/ws/diagnostic", DiagnosticLS),
(r"/ws/diagnostic", ShellcheckLS),
(r"/ws/ruff", RuffLS),
(r"/ws/deno", DenoLS),
(r"/ws/go", GoLS),
+79 -1
View File
@@ -7,7 +7,7 @@ import pytest
from datetime import datetime, timezone
from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async
from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, task_script, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async
class _FakeItems(dict):
@@ -752,6 +752,84 @@ class TestChildModeParallel:
assert result["result"] == 8
class TestSdkCallsInsideTaskBody:
"""A task body runs as one child job, so the SDK calls it makes cannot be
dispatched as steps of their own."""
def _run_child(self, wf, key):
async def bounded():
return await asyncio.wait_for(
_run_workflow_async(wf, {"completed_steps": {}, "_executing_key": key}, {}),
timeout=2,
)
return asyncio.run(bounded())
def test_nested_task_and_step_run_in_the_tasks_own_job(self):
@task
async def inner(x: str):
return f"inner:{x}"
@task
def sync_leaf(x: str):
return f"sync:{x}"
# A sync body calls its tasks without awaiting, whatever called it.
@task
def sync_inner(x: str):
return sync_leaf(x).upper().lower()
@task
async def outer(x: str):
via_task = await inner(x)
via_sync = await sync_inner(x)
via_step = await step("s", lambda: "stepped")
return f"{via_task}+{via_sync}+{via_step}"
@workflow
async def wf():
return await outer("a")
result = self._run_child(wf, "outer")
assert result == {"type": "complete", "result": "inner:a+sync:a+stepped"}
def test_sibling_task_of_the_workflow_body_is_not_run(self):
# The workflow body keeps replaying beside the task body, and its own
# calls must stay parked: running them would execute a sibling task in
# this job.
ran = []
@task
async def inner(x: str):
ran.append(x)
return x
@task
async def outer(x: str):
await asyncio.sleep(0)
return await inner("nested")
@workflow
async def wf():
return await asyncio.gather(outer("a"), inner("sibling"))
result = self._run_child(wf, "outer")
assert result == {"type": "complete", "result": "nested"}
assert ran == ["nested"]
def test_step_that_would_need_a_dispatch_fails_instead_of_hanging(self):
@task
async def dispatching():
return await task_script("f/other/script")()
@workflow
async def wf():
return await dispatching()
with pytest.raises(RuntimeError, match="cannot be called from inside a task"):
self._run_child(wf, "dispatching")
class TestKeyDeterminism:
def test_keys_consistent_across_replays(self):
r1 = _run_workflow(det_wf, {}, {"n": 3})
+82 -6
View File
@@ -2791,6 +2791,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]:
import asyncio as _asyncio
import contextvars as _contextvars
import inspect as _inspect
import sys as _sys
import traceback as _traceback
@@ -3018,6 +3019,64 @@ _workflow_ctx: _contextvars.ContextVar["WorkflowCtx"] = _contextvars.ContextVar(
"_workflow_ctx"
)
# Set while a task body runs in its child job: ``"async"`` or ``"sync"`` after
# the kind of function the body is. A flag on the ctx cannot stand in: under
# ``asyncio.gather`` the workflow body keeps replaying beside the task body, and
# its calls must still park rather than run a sibling task's body in this job.
_in_task_body: _contextvars.ContextVar[Optional[str]] = _contextvars.ContextVar(
"_in_task_body", default=None
)
def _dispatch_ctx() -> "WorkflowCtx | None":
"""The ctx SDK calls dispatch through, or None where they run in place:
outside a workflow, and inside a task body, which is one job and has no
rounds of its own to dispatch or checkpoint a step in."""
if _in_task_body.get():
return None
return _workflow_ctx.get(None)
async def _resolved(value):
return value
def _call_in_task_body(func, caller: str, args, kwargs):
"""Run a task called from inside a task body in place, round-tripping its
result as a dispatched one would be.
The workflow body awaits every task, sync ones included, so an async caller
gets an awaitable back either way; a sync caller gets the value. The scope
is re-marked for the nested body, which may be of the other kind."""
async def run_async(coro):
token = _in_task_body.set("async")
try:
return _json_round_trip(await coro)
finally:
_in_task_body.reset(token)
if _inspect.iscoroutinefunction(func):
return run_async(func(*args, **kwargs))
token = _in_task_body.set("sync")
try:
result = func(*args, **kwargs)
finally:
_in_task_body.reset(token)
if _asyncio.iscoroutine(result):
return run_async(result)
result = _json_round_trip(result)
return _resolved(result) if caller == "async" else result
def _assert_not_inside_task_body(what: str) -> None:
if _in_task_body.get():
raise RuntimeError(
f"{what} cannot be called from inside a task: a task runs as a single "
"job and cannot suspend or dispatch steps. Call it from the @workflow "
"body instead."
)
class WorkflowCtx:
"""Internal context for workflow replay/suspension.
@@ -3142,15 +3201,20 @@ class WorkflowCtx:
raise _StepSuspend({"mode": "sleep", "key": key, "seconds": seconds, "steps": []})
async def _resolved(self, value):
return value
return await _resolved(value)
async def _execute_directly(self, func, **kwargs):
token = _in_task_body.set(
"async" if _inspect.iscoroutinefunction(func) else "sync"
)
try:
result = func(**kwargs)
if _asyncio.iscoroutine(result):
result = await result
except Exception as exc:
raise _StepFailure(exc) from exc
finally:
_in_task_body.reset(token)
raise _StepSuspend({"mode": "step_complete", "steps": [], "result": result})
async def _never_resolve(self):
@@ -3392,7 +3456,9 @@ def task(
- **v2 (inside @workflow)**: dispatches as a checkpoint step.
- **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
- **Standalone**: executes the function body directly.
- **Standalone, or called from inside another v2 task**: executes the
function body directly. A task runs as a single job, so the tasks it
calls run in that same job, without their own options.
A task runs as its own job, so its result is always encoded as JSON and
decoded back before the caller sees it: a ``datetime`` comes back as a
@@ -3472,12 +3538,16 @@ def task(
@functools.wraps(func)
def wrapper(*args, **kwargs):
# WAC v2: inside a @workflow context
ctx = _workflow_ctx.get(None)
ctx = _dispatch_ctx()
if ctx is not None:
script = task_path if task_path else task_name
merged = _merge_args(args, kwargs)
return ctx._next_step(task_name, script, func, _task_options=_task_opts, **merged)
caller = _in_task_body.get()
if caller is not None:
return _call_in_task_body(func, caller, args, kwargs)
# WAC v1: running inside a Windmill job but not in a @workflow
if (
os.environ.get("WM_JOB_ID") is not None
@@ -3556,6 +3626,7 @@ def task_script(
_opts = {k: v for k, v in {"timeout": timeout, "tag": tag, "cache_ttl": cache_ttl, "priority": priority, "concurrent_limit": concurrency_limit, "concurrency_key": concurrency_key, "concurrency_time_window_s": concurrency_time_window_s, "retry": _checked_retry(retry)}.items() if v is not None} or None
def wrapper(**kwargs):
_assert_not_inside_task_body(f'task_script("{path}")')
ctx = _workflow_ctx.get(None)
if ctx is not None:
return ctx._next_step(name, path, dispatch_type="script", _task_options=_opts, **kwargs)
@@ -3595,6 +3666,7 @@ def task_flow(
_opts = {k: v for k, v in {"timeout": timeout, "tag": tag, "cache_ttl": cache_ttl, "priority": priority, "concurrent_limit": concurrency_limit, "concurrency_key": concurrency_key, "concurrency_time_window_s": concurrency_time_window_s, "retry": _checked_retry(retry)}.items() if v is not None} or None
def wrapper(**kwargs):
_assert_not_inside_task_body(f'task_flow("{path}")')
ctx = _workflow_ctx.get(None)
if ctx is not None:
return ctx._next_step(name, path, dispatch_type="flow", _task_options=_opts, **kwargs)
@@ -3629,8 +3701,11 @@ async def step(name: str, fn):
``fn``'s result is encoded as JSON and decoded back before it is returned,
so the round that runs the body sees the same types every replay sees:
a ``datetime`` comes back as a string, a tuple as a list.
Called from inside a task body, ``fn`` runs in place and nothing is
checkpointed: a task is a single job and is never replayed.
"""
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
ctx = _dispatch_ctx()
if ctx is not None:
return await ctx._run_inline_step(name, fn)
result = fn()
@@ -3645,9 +3720,9 @@ async def sleep(seconds: int):
"""Server-side sleep — suspend the workflow for the given duration without holding a worker.
Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.
Outside a workflow, falls back to ``asyncio.sleep``.
Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.
"""
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
ctx = _dispatch_ctx()
if ctx is not None:
return await ctx._sleep(seconds)
await _asyncio.sleep(seconds)
@@ -3685,6 +3760,7 @@ async def wait_for_approval(
await step("notify", lambda: send_email(urls["resume"], urls["cancel"]))
result = await wait_for_approval(key="manager", timeout=3600)
"""
_assert_not_inside_task_body("wait_for_approval()")
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
if ctx is not None:
return await ctx._wait_for_approval(
@@ -30,7 +30,7 @@ app related commands
- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement
- `--port <port:number>` - Port to run the dev server on (will find next available port if occupied)
- `--host <host:string>` - Host to bind the dev server to
- `--entry <entry:string>` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)
- `--entry <entry:string>` - Entry point file (default: index.ts for Svelte, index.tsx otherwise)
- `--no-open` - Don't automatically open the browser
- `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability
@@ -41,7 +41,7 @@ app related commands
- `app new` - create a new raw app from a template
- `--summary <summary:string>` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- `--path <path:string>` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
- `--framework <framework:string>` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.
- `--framework <framework:string>` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode.
- `--datatable <datatable:string>` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.
- `--schema <schema:string>` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it.
- `--overwrite` - Overwrite the target directory if it already exists, without prompting.
+56 -12
View File
@@ -890,7 +890,7 @@ def main(db: postgresql):
export const RAW_APP_BASE = `# Windmill Raw Apps
Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables.
Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.
## App shape
@@ -906,7 +906,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t
### Framework
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for.
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.
### Data setup
@@ -939,7 +939,7 @@ An app the user just started from the new-app dialog is already set up: its file
### Entrypoint
The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`<div id="root">\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` / \`App.vue\` and keep the entrypoint as the mount shim.
The entrypoint is \`index.tsx\` for React and \`index.ts\` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty \`<div id="root">\` and auto-renders nothing, so the entrypoint must mount a top-level \`App\` itself. Keep the UI in \`App.tsx\` / \`App.svelte\` and keep the entrypoint as the mount shim.
React (\`index.tsx\`):
@@ -951,7 +951,7 @@ import App from './App'
createRoot(document.getElementById('root')!).render(<App />)
\`\`\`
Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`. Vue (\`index.ts\`): \`createApp(App).mount('#root')\`.
Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! })\`.
**Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`.
@@ -1116,6 +1116,10 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen
Either one lets those people run the app's backend runnables, so never open an app up unless the user asks.
When a deploy widens who may open the app, tell the user in plain words.
## Sandbox isolation and frontend API access
By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls.
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
@@ -1428,6 +1432,8 @@ async def enrich(customer_id: str):
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`.
Only the workflow body dispatches steps. A task runs as a single job, so a task or \`step()\` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. \`sleep()\` there waits in the job, and \`taskScript()\` / \`taskFlow()\` / \`waitForApproval()\` (\`task_script()\` / \`task_flow()\` / \`wait_for_approval()\`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them.
For existing scripts:
\`\`\`typescript
@@ -2030,6 +2036,9 @@ async sleep(seconds: number): Promise<void>
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -2880,7 +2889,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a \`\`datetime\`\` comes back as a
@@ -2966,12 +2977,15 @@ def workflow(func)
# \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a \`\`datetime\`\` comes back as a string, a tuple as a list.
#
# Called from inside a task body, \`\`fn\`\` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`.
# Outside a workflow, falls back to \`\`asyncio.sleep\`\`.
# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -3093,8 +3107,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro
* const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } });
*
* Inside a \`workflow()\`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly and
* {@link TaskOptions} — retry included — does not apply.
* Outside a workflow, or called from inside another task, the function body
* executes directly and {@link TaskOptions} — retry included — does not apply:
* a task runs as a single job, so the tasks it calls run in that same job.
*
* A task runs as its own job, so its result is always encoded as JSON and
* decoded back before the caller sees it: a \`Date\` comes back as a string, a
@@ -3138,6 +3153,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>)
* \`fn\`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a \`Date\`
* comes back as a string, a \`Map\` as \`{}\`. {@link Jsonified} is that shape.
*
* Called from inside a task body, \`fn\` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -3236,7 +3254,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a \`\`datetime\`\` comes back as a
@@ -3322,12 +3342,15 @@ def workflow(func)
# \`\`fn\`\`'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a \`\`datetime\`\` comes back as a string, a tuple as a list.
#
# Called from inside a task body, \`\`fn\`\` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`.
# Outside a workflow, falls back to \`\`asyncio.sleep\`\`.
# Outside a workflow, or inside a task body, falls back to \`\`asyncio.sleep\`\`.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -3594,7 +3617,7 @@ app related commands
- \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement
- \`--port <port:number>\` - Port to run the dev server on (will find next available port if occupied)
- \`--host <host:string>\` - Host to bind the dev server to
- \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)
- \`--entry <entry:string>\` - Entry point file (default: index.ts for Svelte, index.tsx otherwise)
- \`--no-open\` - Don't automatically open the browser
- \`--recording\` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- \`app lint [app_folder:string]\` - Lint a raw app folder to validate structure and buildability
@@ -3605,7 +3628,7 @@ app related commands
- \`app new\` - create a new raw app from a template
- \`--summary <summary:string>\` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- \`--path <path:string>\` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
- \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.
- \`--framework <framework:string>\` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode.
- \`--datatable <datatable:string>\` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.
- \`--schema <schema:string>\` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it.
- \`--overwrite\` - Overwrite the target directory if it already exists, without prompting.
@@ -5813,6 +5836,27 @@ Name the parameters by adding comments before the statement:
SELECT * FROM users WHERE name = ? AND age > ?;
\`\`\`
## Array parameters
Declare a list with a \`[]\` suffix on the element type (\`(varchar[])\`,
\`(int[])\`), or \`(array)\` for an untyped one. Snowflake cannot bind an array
directly, so Windmill binds the list as JSON text: wrap the placeholder with
\`PARSE_JSON(?)\`, and cast to \`ARRAY\` where a function expects one.
\`\`\`sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY);
\`\`\`
Or join on the elements with \`FLATTEN\`:
\`\`\`sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group;
\`\`\`
## Receiving an S3Object as a script parameter
Declare the arg with type \`(s3object)\`. Windmill renders an S3 file picker for
+31 -2
View File
@@ -1409,6 +1409,27 @@ Name the parameters by adding comments before the statement:
SELECT * FROM users WHERE name = ? AND age > ?;
```
## Array parameters
Declare a list with a `[]` suffix on the element type (`(varchar[])`,
`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array
directly, so Windmill binds the list as JSON text: wrap the placeholder with
`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one.
```sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY);
```
Or join on the elements with `FLATTEN`:
```sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group;
```
## Receiving an S3Object as a script parameter
Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for
@@ -1906,6 +1927,9 @@ async sleep(seconds: number): Promise<void>
* `fn`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a `Date`
* comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.
*
* Called from inside a task body, `fn` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -2756,7 +2780,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a ``datetime`` comes back as a
@@ -2842,12 +2868,15 @@ def workflow(func)
# ``fn``'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a ``datetime`` comes back as a string, a tuple as a list.
#
# Called from inside a task body, ``fn`` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.
# Outside a workflow, falls back to ``asyncio.sleep``.
# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
+7 -2
View File
@@ -672,7 +672,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a ``datetime`` comes back as a
@@ -758,12 +760,15 @@ def workflow(func)
# ``fn``'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a ``datetime`` comes back as a string, a tuple as a list.
#
# Called from inside a task body, ``fn`` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.
# Outside a workflow, falls back to ``asyncio.sleep``.
# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -463,6 +463,9 @@ async sleep(seconds: number): Promise<void>
* `fn`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a `Date`
* comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.
*
* Called from inside a task body, `fn` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -36,7 +36,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a ``datetime`` comes back as a
@@ -122,12 +124,15 @@ def workflow(func)
# ``fn``'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a ``datetime`` comes back as a string, a tuple as a list.
#
# Called from inside a task body, ``fn`` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.
# Outside a workflow, falls back to ``asyncio.sleep``.
# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -67,8 +67,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro
* const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } });
*
* Inside a `workflow()`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly and
* {@link TaskOptions} — retry included — does not apply.
* Outside a workflow, or called from inside another task, the function body
* executes directly and {@link TaskOptions} — retry included — does not apply:
* a task runs as a single job, so the tasks it calls run in that same job.
*
* A task runs as its own job, so its result is always encoded as JSON and
* decoded back before the caller sees it: a `Date` comes back as a string, a
@@ -112,6 +113,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>)
* `fn`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a `Date`
* comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.
*
* Called from inside a task body, `fn` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -35,7 +35,7 @@ app related commands
- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement
- `--port <port:number>` - Port to run the dev server on (will find next available port if occupied)
- `--host <host:string>` - Host to bind the dev server to
- `--entry <entry:string>` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)
- `--entry <entry:string>` - Entry point file (default: index.ts for Svelte, index.tsx otherwise)
- `--no-open` - Don't automatically open the browser
- `--recording` - Frame the app in a shell with a Record button, to capture a replayable session recording of the app under development
- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability
@@ -46,7 +46,7 @@ app related commands
- `app new` - create a new raw app from a template
- `--summary <summary:string>` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.
- `--path <path:string>` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.
- `--framework <framework:string>` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.
- `--framework <framework:string>` - Framework template: react19 | react18 | svelte5. Skips the prompt when provided. Triggers non-interactive mode.
- `--datatable <datatable:string>` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.
- `--schema <schema:string>` - Schema to use with --datatable. If it doesn't exist yet, a sql_to_apply/ migration (CREATE SCHEMA IF NOT EXISTS) is written for it.
- `--overwrite` - Overwrite the target directory if it already exists, without prompting.
@@ -17,7 +17,7 @@ You need three values to run the command:
1. **summary** — a short description of the app
2. **path** — the windmill path, e.g. `f/folder/my_app` or `u/username/my_app`
3. **framework** — one of `react19` (recommended), `react18`, `svelte5`, `vue`
3. **framework** — one of `react19` (recommended), `react18`, `svelte5`
**summary and path**: if the user's request did not supply them, ask. Do not guess values and do not invent paths.
@@ -89,7 +89,7 @@ my_app__raw_app/
├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh)
├── raw_app.yaml # App configuration (summary, path, data settings)
├── index.tsx # Frontend entry point
├── App.tsx # Main React/Svelte/Vue component
├── App.tsx # Main React/Svelte component
├── index.css # Styles
├── package.json # Frontend dependencies
├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT)
@@ -247,7 +247,7 @@ For the rest, tell the user which command fits their intent and let them run it
# Windmill Raw Apps
Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables.
Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.
## App shape
@@ -263,7 +263,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t
### Framework
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for.
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.
### Data setup
@@ -293,7 +293,7 @@ Then build what was chosen: the data table and schema become the app's `data.dat
### Entrypoint
The entrypoint is `index.tsx` for React and `index.ts` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id="root">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` / `App.vue` and keep the entrypoint as the mount shim.
The entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id="root">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.
React (`index.tsx`):
@@ -305,7 +305,7 @@ import App from './App'
createRoot(document.getElementById('root')!).render(<App />)
```
Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`. Vue (`index.ts`): `createApp(App).mount('#root')`.
Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.
**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.
@@ -471,6 +471,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen
Either one lets those people run the app's backend runnables, so never open an app up unless the user asks.
`public: true` in `raw_app.yaml` deploys the app for anonymous users, and `guests: true` for guests. Remove the line and the next push closes the app again.
## Sandbox isolation and frontend API access
By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls.
In `raw_app.yaml`:
```yaml
sandbox: true
frontend_api: [jobs:run, users:read]
```
A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise.
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
@@ -659,6 +659,9 @@ async sleep(seconds: number): Promise<void>
* `fn`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a `Date`
* comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.
*
* Called from inside a task body, `fn` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -671,6 +671,9 @@ async sleep(seconds: number): Promise<void>
* `fn`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a `Date`
* comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.
*
* Called from inside a task body, `fn` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -661,6 +661,9 @@ async sleep(seconds: number): Promise<void>
* `fn`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a `Date`
* comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.
*
* Called from inside a task body, `fn` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
async step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -880,7 +880,9 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]]
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a ``datetime`` comes back as a
@@ -966,12 +968,15 @@ def workflow(func)
# ``fn``'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a ``datetime`` comes back as a string, a tuple as a list.
#
# Called from inside a task body, ``fn`` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.
# Outside a workflow, falls back to ``asyncio.sleep``.
# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
@@ -84,6 +84,27 @@ Name the parameters by adding comments before the statement:
SELECT * FROM users WHERE name = ? AND age > ?;
```
## Array parameters
Declare a list with a `[]` suffix on the element type (`(varchar[])`,
`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array
directly, so Windmill binds the list as JSON text: wrap the placeholder with
`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one.
```sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY);
```
Or join on the elements with `FLATTEN`:
```sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group;
```
## Receiving an S3Object as a script parameter
Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for
@@ -143,6 +143,8 @@ async def enrich(customer_id: str):
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`.
Only the workflow body dispatches steps. A task runs as a single job, so a task or `step()` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. `sleep()` there waits in the job, and `taskScript()` / `taskFlow()` / `waitForApproval()` (`task_script()` / `task_flow()` / `wait_for_approval()`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them.
For existing scripts:
```typescript
@@ -308,8 +310,9 @@ export async function getResumeUrls(approver?: string, flowLevel?: boolean): Pro
* const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } });
*
* Inside a `workflow()`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly and
* {@link TaskOptions} — retry included — does not apply.
* Outside a workflow, or called from inside another task, the function body
* executes directly and {@link TaskOptions} — retry included — does not apply:
* a task runs as a single job, so the tasks it calls run in that same job.
*
* A task runs as its own job, so its result is always encoded as JSON and
* decoded back before the caller sees it: a `Date` comes back as a string, a
@@ -353,6 +356,9 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>)
* `fn`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a `Date`
* comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.
*
* Called from inside a task body, `fn` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
export async function step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>
@@ -451,7 +457,9 @@ def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
# - **Standalone, or called from inside another v2 task**: executes the
# function body directly. A task runs as a single job, so the tasks it
# calls run in that same job, without their own options.
#
# A task runs as its own job, so its result is always encoded as JSON and
# decoded back before the caller sees it: a ``datetime`` comes back as a
@@ -537,12 +545,15 @@ def workflow(func)
# ``fn``'s result is encoded as JSON and decoded back before it is returned,
# so the round that runs the body sees the same types every replay sees:
# a ``datetime`` comes back as a string, a tuple as a list.
#
# Called from inside a task body, ``fn`` runs in place and nothing is
# checkpointed: a task is a single job and is never replayed.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.
# Outside a workflow, falls back to ``asyncio.sleep``.
# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
+2 -2
View File
@@ -12,7 +12,7 @@ You need three values to run the command:
1. **summary** — a short description of the app
2. **path** — the windmill path, e.g. `f/folder/my_app` or `u/username/my_app`
3. **framework** — one of `react19` (recommended), `react18`, `svelte5`, `vue`
3. **framework** — one of `react19` (recommended), `react18`, `svelte5`
**summary and path**: if the user's request did not supply them, ask. Do not guess values and do not invent paths.
@@ -84,7 +84,7 @@ my_app__raw_app/
├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh)
├── raw_app.yaml # App configuration (summary, path, data settings)
├── index.tsx # Frontend entry point
├── App.tsx # Main React/Svelte/Vue component
├── App.tsx # Main React/Svelte component
├── index.css # Styles
├── package.json # Frontend dependencies
├── wmill.ts # Auto-generated backend type definitions (DO NOT EDIT)
+18 -4
View File
@@ -1,6 +1,6 @@
# Windmill Raw Apps
Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables.
Raw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.
## App shape
@@ -16,7 +16,7 @@ A new app gets the setup Windmill's own new-app dialog gives one: a framework, t
### Framework
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18, Svelte 5 and Vue are for when they are asked for.
Use the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.
### Data setup
@@ -54,7 +54,7 @@ An app the user just started from the new-app dialog is already set up: its file
### Entrypoint
The entrypoint is `index.tsx` for React and `index.ts` for Svelte and Vue. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id="root">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` / `App.vue` and keep the entrypoint as the mount shim.
The entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id="root">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.
React (`index.tsx`):
@@ -66,7 +66,7 @@ import App from './App'
createRoot(document.getElementById('root')!).render(<App />)
```
Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`. Vue (`index.ts`): `createApp(App).mount('#root')`.
Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.
**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.
@@ -239,6 +239,20 @@ Either one lets those people run the app's backend runnables, so never open an a
When a deploy widens who may open the app, tell the user in plain words.
<!-- /chat-only -->
## 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.
<!-- cli-only -->
In `raw_app.yaml`:
```yaml
sandbox: true
frontend_api: [jobs:run, users:read]
```
A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise.
<!-- /cli-only -->
## Data Tables
Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.
+2
View File
@@ -95,6 +95,8 @@ async def enrich(customer_id: str):
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`.
Only the workflow body dispatches steps. A task runs as a single job, so a task or `step()` called from inside a task body runs in place in that job, as a plain function call: no child job, no checkpoint, and its task options (retry, timeout, tag) do not apply. `sleep()` there waits in the job, and `taskScript()` / `taskFlow()` / `waitForApproval()` (`task_script()` / `task_flow()` / `wait_for_approval()`) throw. To get separate jobs, call the tasks one after another from the workflow body and pass results between them.
For existing scripts:
```typescript
+21
View File
@@ -10,6 +10,27 @@ Name the parameters by adding comments before the statement:
SELECT * FROM users WHERE name = ? AND age > ?;
```
## Array parameters
Declare a list with a `[]` suffix on the element type (`(varchar[])`,
`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array
directly, so Windmill binds the list as JSON text: wrap the placeholder with
`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one.
```sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
WHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY);
```
Or join on the elements with `FLATTEN`:
```sql
-- ? groups (varchar[])
SELECT o.* FROM orders o
JOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group;
```
## Receiving an S3Object as a script parameter
Declare the arg with type `(s3object)`. Windmill renders an S3 file picker for
+64 -11
View File
@@ -1832,6 +1832,52 @@ export function setWorkflowCtx(ctx: WorkflowCtx | null) {
Reflect.set(globalThis, "__wmill_wf_ctx", ctx);
}
type TaskBodyScope = { run<R>(store: true, fn: () => R): R; getStore(): true | undefined };
/** Marks the async context of a task body running in its child job. A flag on
* the ctx cannot stand in: the workflow body keeps replaying beside the task
* body (`Promise.all([a(), b()])`), and its calls must still park rather than
* run a sibling task's body in this job. Kept on `globalThis`, like the ctx,
* so a bundle holding two copies of this module shares one scope. Resolved
* through `process.getBuiltinModule` because a `node:` import would break
* browser bundles of this package. */
function taskBodyScope(): TaskBodyScope | null {
const existing = Reflect.get(globalThis, "__wmill_wf_task_body");
if (existing !== undefined) return existing;
let scope: TaskBodyScope | null = null;
try {
const AsyncLocalStorage = (globalThis as any).process?.getBuiltinModule?.("node:async_hooks")?.AsyncLocalStorage;
if (AsyncLocalStorage) scope = new AsyncLocalStorage();
} catch {}
Reflect.set(globalThis, "__wmill_wf_task_body", scope);
return scope;
}
function runTaskBody<R>(fn: () => R): R {
const scope = taskBodyScope();
return scope ? scope.run(true, fn) : fn();
}
function insideTaskBody(): boolean {
return taskBodyScope()?.getStore() === true;
}
/** The ctx SDK calls dispatch through, or null where they run in place: outside
* a workflow, and inside a task body, which is one job and has no rounds of its
* own to dispatch or checkpoint a step in. */
function dispatchCtx(): WorkflowCtx | null {
if (insideTaskBody()) return null;
return _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
}
function assertNotInsideTaskBody(what: string): void {
if (insideTaskBody()) {
throw new Error(
`${what} cannot be called from inside a task: a task runs as a single job and cannot suspend or dispatch steps. Call it from the workflow() body instead.`,
);
}
}
export class WorkflowCtx {
private completed: Record<string, any>;
@@ -2304,11 +2350,11 @@ export class WorkflowCtx {
}
export async function sleep(seconds: number): Promise<void> {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
const ctx = dispatchCtx();
if (ctx) {
return ctx._sleep(seconds) as Promise<void>;
}
// Outside workflow context, just wait locally
// Outside a workflow, or inside a task body, just wait locally
await new Promise((r) => setTimeout(r, seconds * 1000));
}
@@ -2319,12 +2365,15 @@ export async function sleep(seconds: number): Promise<void> {
* `fn`'s result is encoded as JSON and decoded back before it is returned, so
* the round that runs the body sees the same types every replay sees: a `Date`
* comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.
*
* Called from inside a task body, `fn` runs in place and nothing is
* checkpointed: a task is a single job and is never replayed.
*/
export async function step<T>(
name: string,
fn: () => T | Promise<T>,
): Promise<Jsonified<Awaited<T>>> {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
const ctx = dispatchCtx();
if (ctx) {
return ctx._runInlineStep(name, fn) as Promise<Jsonified<Awaited<T>>>;
}
@@ -2342,8 +2391,9 @@ export async function step<T>(
* const call_api = task(fetchOrders, { retry: { attempts: 3, delay: 30, multiplier: 2 } });
*
* Inside a `workflow()`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly and
* {@link TaskOptions} — retry included — does not apply.
* Outside a workflow, or called from inside another task, the function body
* executes directly and {@link TaskOptions} — retry included — does not apply:
* a task runs as a single job, so the tasks it calls run in that same job.
*
* A task runs as its own job, so its result is always encoded as JSON and
* decoded back before the caller sees it: a `Date` comes back as a string, a
@@ -2377,7 +2427,7 @@ export function task<T extends (...args: any[]) => Promise<any>>(
// .then() which throws StepSuspend and empties pending before the caller
// can flush.
const wrapper = function (...args: any[]) {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
const ctx = dispatchCtx();
if (ctx) {
// Inside a workflow with checkpoint/replay context — dispatch as step
const script = taskPath ?? taskName;
@@ -2397,7 +2447,7 @@ export function task<T extends (...args: any[]) => Promise<any>>(
return (async () => {
let result: any;
try {
result = await fn(...args);
result = await runTaskBody(() => fn(...args));
} catch (e) {
if (isSuspendSignal(e, StepSuspend)) throw e;
ctx._raiseStepFailure(e);
@@ -2406,7 +2456,7 @@ export function task<T extends (...args: any[]) => Promise<any>>(
})();
}
return stepResult;
} else if (getEnv("WM_JOB_ID") && !getEnv("WM_FLOW_JOB_ID")) {
} else if (!insideTaskBody() && getEnv("WM_JOB_ID") && !getEnv("WM_FLOW_JOB_ID")) {
// Inside a Windmill root job without checkpoint context — v1 HTTP dispatch
// WM_FLOW_JOB_ID is set on child jobs, so we skip dispatch for those
return (async () => {
@@ -2456,7 +2506,8 @@ export function taskScript(path: string, options?: TaskOptions): (...args: any[]
assertUsableRetry(options?.retry);
const name = path.split("/").pop() || path;
const wrapper = function (...args: any[]) {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
assertNotInsideTaskBody(`taskScript("${path}")`);
const ctx = dispatchCtx();
if (ctx) {
const kwargs = args.length === 1 && typeof args[0] === "object" && args[0] !== null
? args[0]
@@ -2482,7 +2533,8 @@ export function taskFlow(path: string, options?: TaskOptions): (...args: any[])
assertUsableRetry(options?.retry);
const name = path.split("/").pop() || path;
const wrapper = function (...args: any[]) {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
assertNotInsideTaskBody(`taskFlow("${path}")`);
const ctx = dispatchCtx();
if (ctx) {
const kwargs = args.length === 1 && typeof args[0] === "object" && args[0] !== null
? args[0]
@@ -2535,7 +2587,8 @@ export function waitForApproval(options?: {
skin?: "detailed" | "minimal";
description?: string | object;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
assertNotInsideTaskBody("waitForApproval()");
const ctx = dispatchCtx();
if (!ctx) {
throw new Error("waitForApproval can only be called inside a workflow()");
}
@@ -0,0 +1,91 @@
/**
* A task body runs as one child job, so the SDK calls it makes cannot be
* dispatched as steps of their own. Against the real client.
*
* Run with: bun test typescript-client/tests/workflow_nested_task.test.ts
*/
import { expect, test, describe, mock, afterEach } from "bun:test";
mock.module("../services.gen", () => ({
ResourceService: {},
VariableService: {},
JobService: {},
HelpersService: {},
AppService: {},
MetricsService: {},
OidcService: {},
UserService: {},
KafkaTriggerService: {},
}));
mock.module("../core/OpenAPI", () => ({
OpenAPI: { BASE: "http://localhost:8000/api", TOKEN: "tok" },
}));
const { WorkflowCtx, StepSuspend, task, taskScript, step, setWorkflowCtx } = await import(
"../client.ts"
);
let ran: string[] = [];
const inner = task(async function inner(x: string) {
ran.push("inner");
return `inner:${x}`;
});
const outer = task(async function outer(x: string) {
ran.push("outer");
await Promise.resolve();
const viaTask = await inner(x);
const viaStep = await step("s", () => "stepped");
return `${viaTask}+${viaStep}`;
});
const dispatching = task(async function dispatching() {
return await taskScript("f/other/script")({});
});
/** Run `body` as the child round executing `key`, and hand back what it raised. */
async function childRound(key: string, body: () => Promise<unknown>): Promise<any> {
setWorkflowCtx(new WorkflowCtx({ completed_steps: {}, _executing_key: key }));
const hung = new Promise((r) => setTimeout(() => r("hung"), 500));
return await Promise.race([body().then(() => "returned", (e) => e), hung]);
}
afterEach(() => {
ran = [];
setWorkflowCtx(null);
});
describe("SDK calls inside a task body", () => {
test("a nested task and step run in the task's own job", async () => {
const raised = await childRound("outer", async () => {
await outer("a");
});
expect(raised).toBeInstanceOf(StepSuspend);
expect(raised.dispatchInfo).toMatchObject({
mode: "step_complete",
result: "inner:a+stepped",
});
});
// The workflow body keeps replaying beside the task body, and its own calls
// must stay parked: running them would execute a sibling task in this job.
test("a sibling task of the workflow body is not run", async () => {
const raised = await childRound("outer", async () => {
await Promise.all([outer("a"), inner("sibling")]);
});
expect(raised.dispatchInfo).toMatchObject({ mode: "step_complete" });
expect(ran).toEqual(["outer", "inner"]);
});
test("a step that would need a dispatch fails instead of hanging", async () => {
const raised = await childRound("dispatching", async () => {
await dispatching();
});
expect(raised).toBeInstanceOf(Error);
expect(raised.message).toContain("cannot be called from inside a task");
});
});