use anyhow::Context; use axum::response::IntoResponse; use http::StatusCode; use serde::Deserialize; use serde_json::value::RawValue; use sqlx::types::Json; use std::collections::HashMap; use std::future::Future; use uuid::Uuid; use windmill_common::{ db::{UserDB, UserDbWithAuthed, DB}, error::Result, flows::{FlowModuleValue, Retry}, get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path, jobs::{get_has_preprocessor_from_content_and_lang, script_path_to_payload, JobPayload}, scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, triggers::{ HubOrWorkspaceId, RunnableFormat, RunnableFormatVersion, TriggerKind, TriggerMetadata, RUNNABLE_FORMAT_VERSION_CACHE, }, users::username_to_permissioned_as, utils::{StripPath, HTTP_CLIENT}, worker::to_raw_value, }; use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel}; use windmill_api_auth::{check_scopes, ApiAuthed}; #[cfg(feature = "enterprise")] use windmill_api_jobs::execution::check_license_key_valid; use windmill_api_jobs::{ execution::{ check_tag_available_for_workspace, delete_job_metadata_after_use, push_flow_job_by_path_into_queue, push_script_job_by_path_into_queue, result_to_response, run_wait_result_internal, schedule_job_deletion, }, types::RunJobQuery, }; struct ScriptInfo { has_preprocessor: Option, language: ScriptLang, content: String, schema: Option>, } #[derive(Debug, Deserialize)] struct PropertyDefinition { r#type: Option>, } #[derive(Debug, Deserialize)] struct PartialSchema { properties: Option>, } #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum RunnableId { FlowId(FlowId), ScriptId(ScriptId), HubScript(String), } impl RunnableId { pub fn from_script_hash(hash: ScriptHash) -> Self { Self::ScriptId(ScriptId::ScriptHash(hash)) } pub fn from_script_path(path: &str) -> Self { if path.starts_with("hub/") { Self::HubScript(path.to_string()) } else { Self::ScriptId(ScriptId::ScriptPath(path.to_string())) } } pub fn from_flow_path(path: &str) -> Self { Self::FlowId(FlowId::FlowPath(path.to_string())) } pub fn from_flow_version(version: i64) -> Self { Self::FlowId(FlowId::FlowVersion(version)) } } #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum FlowId { FlowPath(String), FlowVersion(i64), } impl FlowId { async fn get_flow_version_id(self, workspace_id: &str, db: &DB) -> Result { let version_id = match self { FlowId::FlowPath(path) => { let info = get_latest_flow_version_info_for_path(None, db, workspace_id, &path, true) .await?; info.version } FlowId::FlowVersion(version) => version, }; Ok(version_id) } } #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum ScriptId { ScriptPath(String), ScriptHash(ScriptHash), } impl ScriptId { async fn get_script_hash(self, workspace_id: &str, db: &DB) -> Result { let hash = match self { ScriptId::ScriptPath(path) => { let info = get_latest_deployed_hash_for_path(None, db.clone(), workspace_id, &path) .await?; info.hash } ScriptId::ScriptHash(hash) => hash.0, }; Ok(hash) } } async fn get_script_info( db: &DB, workspace_id: &str, hash: i64, ) -> std::result::Result { sqlx::query_as!(ScriptInfo, "SELECT has_preprocessor, language as \"language: _\", content, schema as \"schema: _\" FROM script WHERE workspace_id = $1 AND hash = $2", workspace_id, hash) .fetch_one(db) .await } fn runnable_format_from_schema_without_preprocessor( trigger_kind: &TriggerKind, schema: Option>, ) -> RunnableFormat { match trigger_kind { TriggerKind::Mqtt | TriggerKind::Amqp if schema.as_ref().is_some_and(|schema| { schema.properties.as_ref().is_some_and(|properties| { properties.iter().any(|(key, def)| { key == "payload" && def.r#type.as_ref().is_some_and(|t| { let typ = t.get().trim(); typ == "array" || (typ.starts_with('[') && typ.ends_with(']')) }) }) }) }) => { RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: false } } TriggerKind::Kafka | TriggerKind::Nats if schema.as_ref().is_some_and(|schema| { schema .properties .as_ref() .is_some_and(|properties| properties.keys().any(|key| key == "msg")) }) => { RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: false } } _ => RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: false }, } } fn runnable_format_from_preprocessor_args( args: Option>, ) -> RunnableFormat { if let Some(args) = args { if args.iter().any(|arg| arg.name == "wm_trigger") || (args.len() > 0 && args.iter().all(|arg| arg.name != "event")) { RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: true } } else { RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: true } } } else { RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: true } } } enum PreprocessorInfo { Preprocessor { content: String, language: ScriptLang }, NoPreprocessor { schema: Option> }, } #[derive(Debug, Deserialize)] struct FlowInfo { preprocessor_module: Option>, schema: Option>, } fn get_preprocessor_args_from_content_and_language( content: &str, language: &ScriptLang, ) -> Result>> { let args = match language { ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { let args = windmill_parser_ts::parse_deno_signature( &content, true, false, Some("preprocessor".to_string()), )?; Some(args.args) } #[cfg(feature = "python")] ScriptLang::Python3 => { let args = windmill_parser_py::parse_python_signature( &content, Some("preprocessor".to_string()), false, )?; Some(args.args) } _ => None, }; Ok(args) } pub async fn get_runnable_format( runnable_id: RunnableId, workspace_id: &str, db: &DB, trigger_kind: &TriggerKind, ) -> Result { let (key, preprocessor_info) = match runnable_id { RunnableId::HubScript(path) => { let Some(version) = path.split("/").nth(1) else { return Err(windmill_common::error::Error::internal_err( "Invalid hub script path".to_string(), )); }; let version = match version.parse::() { Ok(version) => version, Err(_) => { return Err(windmill_common::error::Error::internal_err( "Invalid hub script version".to_string(), )); } }; let key = (HubOrWorkspaceId::Hub, version, trigger_kind.clone()); let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key); if let Some(runnable_format) = runnable_format { tracing::debug!("Using cached runnable format for hub script {path}"); return Ok(runnable_format); } let hub_script = get_full_hub_script_by_path(StripPath(path.to_string()), &HTTP_CLIENT, Some(db)) .await?; let has_preprocessor = get_has_preprocessor_from_content_and_lang( &hub_script.content, &hub_script.language, )?; let partial_schema = serde_json::from_str(hub_script.schema.get())?; ( key, if has_preprocessor { PreprocessorInfo::Preprocessor { content: hub_script.content, language: hub_script.language, } } else { PreprocessorInfo::NoPreprocessor { schema: Some(sqlx::types::Json(partial_schema)), } }, ) } RunnableId::FlowId(flow_id) => { let version = flow_id.get_flow_version_id(workspace_id, db).await?; let key = ( HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()), version, trigger_kind.clone(), ); let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key); if let Some(runnable_format) = runnable_format { tracing::debug!("Using cached runnable format for flow version {version}"); return Ok(runnable_format); } let flow_info = sqlx::query_as!( FlowInfo, "SELECT value->'preprocessor_module'->'value' as \"preprocessor_module: _\", schema as \"schema: _\" FROM flow_version WHERE id = $1 AND workspace_id = $2", version, workspace_id, ) .fetch_one(db) .await?; if let Some(preprocessor_module) = flow_info.preprocessor_module { match preprocessor_module.0 { FlowModuleValue::RawScript { content, language, .. } => { (key, PreprocessorInfo::Preprocessor { content, language }) } FlowModuleValue::Script { path, hash, .. } => { let hash = if let Some(hash) = hash { hash.0 } else { let script_hash = get_latest_deployed_hash_for_path( None, db.clone(), workspace_id, &path, ) .await?; script_hash.hash }; let script_info = get_script_info(db, workspace_id, hash).await?; ( key, PreprocessorInfo::Preprocessor { content: script_info.content, language: script_info.language, }, ) } _ => { return Err(windmill_common::error::Error::internal_err( "Unsupported preprocessor module".to_string(), )); } } } else { ( key, PreprocessorInfo::NoPreprocessor { schema: flow_info.schema }, ) } } RunnableId::ScriptId(script_id) => { let hash = script_id.get_script_hash(workspace_id, db).await?; let key = ( HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()), hash, trigger_kind.clone(), ); let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key); if let Some(runnable_format) = runnable_format { tracing::debug!("Using cached runnable format for script {hash}"); return Ok(runnable_format); } let script_info = get_script_info(db, workspace_id, hash).await?; if script_info.has_preprocessor.unwrap_or(false) { ( key, PreprocessorInfo::Preprocessor { content: script_info.content, language: script_info.language, }, ) } else { ( key, PreprocessorInfo::NoPreprocessor { schema: script_info.schema }, ) } } }; let runnable_format = match preprocessor_info { PreprocessorInfo::Preprocessor { content, language } => { let args = get_preprocessor_args_from_content_and_language(&content, &language)?; runnable_format_from_preprocessor_args(args) } PreprocessorInfo::NoPreprocessor { schema } => { runnable_format_from_schema_without_preprocessor(trigger_kind, schema) } }; RUNNABLE_FORMAT_VERSION_CACHE.insert(key, runnable_format); Ok(runnable_format) } #[allow(dead_code)] pub trait TriggerJobArgs { type Payload: Send + Sync; const TRIGGER_KIND: TriggerKind; fn v1_payload_fn(payload: &Self::Payload) -> HashMap>; fn v2_payload_fn(payload: &Self::Payload) -> HashMap> { Self::v1_payload_fn(payload) } fn build_job_args_v2( has_preprocessor: bool, payload: &Self::Payload, info: HashMap>, ) -> PushArgsOwned { let mut args = Self::v2_payload_fn(payload); if has_preprocessor { args.insert( "kind".to_string(), to_raw_value(&Self::TRIGGER_KIND.to_key()), ); args.extend(info); let args = HashMap::from([("event".to_string(), to_raw_value(&args))]); PushArgsOwned { args, extra: None } } else { PushArgsOwned { args, extra: None } } } fn build_job_args_v1( has_preprocessor: bool, payload: &Self::Payload, info: HashMap>, ) -> PushArgsOwned { let trigger_key = Self::TRIGGER_KIND.to_key(); let args = Self::v1_payload_fn(payload); let extra = if has_preprocessor { Some(HashMap::from([( "wm_trigger".to_string(), to_raw_value(&serde_json::json!({ "kind": trigger_key, trigger_key: info })), )])) } else { None }; PushArgsOwned { args, extra } } fn build_job_args( runnable_path: &str, is_flow: bool, w_id: &str, db: &DB, payload: Self::Payload, info: HashMap>, ) -> impl Future> + Send { async move { let runnable_id = if is_flow { RunnableId::from_flow_path(runnable_path) } else { RunnableId::from_script_path(runnable_path) }; Self::build_job_args_from_runnable_id(runnable_id, w_id, db, payload, info).await } } fn build_job_args_from_runnable_id( runnable_id: RunnableId, w_id: &str, db: &DB, payload: Self::Payload, trigger_info: HashMap>, ) -> impl Future> + Send { async move { tracing::debug!("Building job args for {runnable_id:?}"); let runnable_format = get_runnable_format(runnable_id, w_id, db, &Self::TRIGGER_KIND).await?; let job_args = match runnable_format { RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor } => { Self::build_job_args_v1(has_preprocessor, &payload, trigger_info) } RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor } => { Self::build_job_args_v2(has_preprocessor, &payload, trigger_info) } }; Ok(job_args) } } fn build_capture_payloads( payload: &Self::Payload, info: HashMap>, ) -> (PushArgsOwned, PushArgsOwned) { let main_args = Self::build_job_args_v2(false, payload, info.clone()); let preprocessor_args = Self::build_job_args_v2(true, payload, info); (main_args, preprocessor_args) } } #[allow(dead_code)] pub async fn trigger_runnable_inner<'c>( db: &DB, tx_o: Option>, user_db: Option, authed: ApiAuthed, workspace_id: &str, runnable_path: &str, is_flow: bool, args: PushArgsOwned, retry: Option<&sqlx::types::Json>, error_handler_path: Option<&str>, error_handler_args: Option<&sqlx::types::Json>>, trigger_path: String, job_id: Option, trigger: TriggerMetadata, suspended_mode: Option, ) -> Result<( Uuid, Option, Option, bool, Option>, )> { let error_handler_args = error_handler_args.map(|args| { let args = args .0 .iter() .map(|(key, value)| (key.to_owned(), to_raw_value(&value))) .collect::>>(); Json(args) }); let user_db = user_db.unwrap_or_else(|| UserDB::new(db.clone())); let (uuid, resolved_delete_secs, early_return, has_failure_module, tx_out) = if is_flow { let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() }; let path = StripPath(runnable_path.to_string()); let (uuid, early_return, has_failure_module, tx_out) = push_flow_job_by_path_into_queue( authed, db.clone(), tx_o, user_db, workspace_id.to_string(), path, run_query, args, Some(trigger), ) .await?; (uuid, None, early_return, has_failure_module, tx_out) } else { let (uuid, resolved_delete_secs, tx_out) = trigger_script_internal( db, tx_o, user_db, authed, workspace_id, runnable_path, args, retry, error_handler_path, error_handler_args.as_ref(), trigger_path, job_id, trigger, suspended_mode, ) .await?; (uuid, resolved_delete_secs, None, false, tx_out) }; Ok(( uuid, resolved_delete_secs, early_return, has_failure_module, tx_out, )) } #[allow(dead_code)] pub async fn trigger_runnable( db: &DB, user_db: Option, authed: ApiAuthed, workspace_id: &str, runnable_path: &str, is_flow: bool, args: PushArgsOwned, retry: Option<&sqlx::types::Json>, error_handler_path: Option<&str>, error_handler_args: Option<&sqlx::types::Json>>, trigger_path: String, job_id: Option, suspended_mode: bool, trigger: TriggerMetadata, ) -> Result { let uuid = trigger_runnable_inner( db, None, user_db, authed, workspace_id, runnable_path, is_flow, args, retry, error_handler_path, error_handler_args, trigger_path, job_id, trigger, Some(suspended_mode), ) .await? .0; Ok((StatusCode::CREATED, uuid.to_string()).into_response()) } #[allow(dead_code)] pub async fn trigger_runnable_and_wait_for_result( db: &DB, user_db: Option, authed: ApiAuthed, workspace_id: &str, runnable_path: &str, is_flow: bool, args: PushArgsOwned, retry: Option<&sqlx::types::Json>, error_handler_path: Option<&str>, error_handler_args: Option<&sqlx::types::Json>>, trigger_path: String, trigger: TriggerMetadata, ) -> Result { let username = authed.username.clone(); let (uuid, resolved_delete_secs, early_return, has_failure_module, _) = trigger_runnable_inner( db, None, user_db, authed, workspace_id, runnable_path, is_flow, args, retry, error_handler_path, error_handler_args, trigger_path, None, trigger, None, ) .await?; let (result, success) = run_wait_result_internal( db, uuid, &workspace_id, early_return, has_failure_module, &username, ) .await?; match resolved_delete_secs { Some(0) => delete_job_metadata_after_use(&db, uuid).await?, Some(secs) => schedule_job_deletion(&db, uuid, &workspace_id, secs).await?, None => {} } result_to_response(result, success) } #[allow(dead_code)] pub async fn trigger_runnable_and_wait_for_raw_result( db: &DB, user_db: Option, authed: ApiAuthed, workspace_id: &str, runnable_path: &str, is_flow: bool, args: PushArgsOwned, retry: Option<&sqlx::types::Json>, error_handler_path: Option<&str>, error_handler_args: Option<&sqlx::types::Json>>, trigger_path: String, trigger: TriggerMetadata, ) -> Result<(Box, bool)> { let username = authed.username.clone(); let (uuid, resolved_delete_secs, early_return, has_failure_module, _) = trigger_runnable_inner( db, None, user_db, authed, workspace_id, runnable_path, is_flow, args, retry, error_handler_path, error_handler_args, trigger_path, None, trigger, None, ) .await?; let (result, success) = run_wait_result_internal( db, uuid, &workspace_id, early_return, has_failure_module, &username, ) .await .with_context(|| { format!( "Error fetching job result for {} {}", if is_flow { "flow" } else { "script" }, runnable_path ) })?; match resolved_delete_secs { Some(0) => delete_job_metadata_after_use(&db, uuid).await?, Some(secs) => schedule_job_deletion(&db, uuid, &workspace_id, secs).await?, None => {} } Ok((result, success)) } pub async fn trigger_runnable_and_wait_for_raw_result_with_error_ctx( db: &DB, user_db: Option, authed: ApiAuthed, workspace_id: &str, runnable_path: &str, is_flow: bool, args: PushArgsOwned, retry: Option<&sqlx::types::Json>, error_handler_path: Option<&str>, error_handler_args: Option<&sqlx::types::Json>>, trigger_path: String, trigger: TriggerMetadata, ) -> Result> { let (result, success) = trigger_runnable_and_wait_for_raw_result( db, user_db, authed, workspace_id, runnable_path, is_flow, args, retry, error_handler_path, error_handler_args, trigger_path, trigger, ) .await?; if !success { Err(windmill_common::error::Error::internal_err(format!( "{} {runnable_path} failed: {:?}", if is_flow { "Flow" } else { "Script" }, result ))) } else { Ok(result) } } async fn trigger_script_internal<'c>( db: &DB, tx_o: Option>, user_db: UserDB, authed: ApiAuthed, workspace_id: &str, script_path: &str, args: PushArgsOwned, retry: Option<&sqlx::types::Json>, error_handler_path: Option<&str>, error_handler_args: Option<&sqlx::types::Json>>>, trigger_path: String, job_id: Option, trigger: TriggerMetadata, suspended_mode: Option, ) -> Result<( Uuid, Option, Option>, )> { if retry.is_none() && error_handler_path.is_none() { let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() }; let path = StripPath(script_path.to_string()); let (uuid, resolved_delete_secs, tx_out) = push_script_job_by_path_into_queue( authed, db.clone(), tx_o, user_db, workspace_id.to_string(), path, run_query, args, Some(trigger), ) .await?; Ok((uuid, resolved_delete_secs, tx_out)) } else { let (uuid, resolved_delete_secs, tx_out) = trigger_script_with_retry_and_error_handler( db, tx_o, user_db, authed, workspace_id, script_path, args, retry, error_handler_path, error_handler_args, trigger_path, job_id, trigger, suspended_mode, ) .await?; Ok((uuid, resolved_delete_secs, tx_out)) } } async fn trigger_script_with_retry_and_error_handler<'c>( db: &DB, tx_o: Option>, user_db: UserDB, authed: ApiAuthed, workspace_id: &str, script_path: &str, args: PushArgsOwned, retry: Option<&sqlx::types::Json>, error_handler_path: Option<&str>, error_handler_args: Option<&sqlx::types::Json>>>, trigger_path: String, job_id: Option, trigger: TriggerMetadata, suspended_mode: Option, ) -> Result<( Uuid, Option, Option>, )> { #[cfg(feature = "enterprise")] check_license_key_valid().await?; check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?; let retry = retry.map(|r| r.0.clone()); let error_handler_path = error_handler_path.map(|p| p.to_string()); let error_handler_args = error_handler_args.map(|args| args.0.clone()); let (job_payload, tag, delete_after_use, delete_after_secs, timeout, on_behalf_of) = { let db_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() }; script_path_to_payload( script_path, Some(db_authed), db.clone(), &workspace_id, Some(false), ) .await? }; let resolved_delete_secs = windmill_api_jobs::execution::resolve_delete_after_secs( delete_after_use, delete_after_secs, ); check_tag_available_for_workspace(&db, &workspace_id, &tag, &authed).await?; let return_tx = tx_o.is_some(); let (email, permissioned_as, push_authed, tx) = if let Some(tx) = tx_o { ( authed.email.as_str(), username_to_permissioned_as(&authed.username), Some(authed.clone().into()), PushIsolationLevel::Transaction(tx), ) } else if let Some(on_behalf_of) = on_behalf_of.as_ref() { ( on_behalf_of.email.as_str(), on_behalf_of.permissioned_as.clone(), None, PushIsolationLevel::IsolatedRoot(db.clone()), ) } else { ( authed.email.as_str(), username_to_permissioned_as(&authed.username), Some(authed.clone().into()), PushIsolationLevel::Isolated(user_db, authed.clone().into()), ) }; let push_args = PushArgs { args: &args.args, extra: args.extra }; let retryable_job_payload = match job_payload { JobPayload::ScriptHash { hash, path, concurrency_settings, debouncing_settings, cache_ttl, cache_ignore_s3_path, priority, apply_preprocessor, .. } => JobPayload::SingleStepFlow { path, hash: Some(hash), flow_version: None, // Keep the flow path until native retry covers handler semantics. language: None, args: HashMap::from(&push_args), retry, error_handler_path, error_handler_args, skip_handler: None, cache_ttl, cache_ignore_s3_path, priority, tag_override: tag.clone(), apply_preprocessor, trigger_path: Some(trigger_path.clone()), concurrency_settings, debouncing_settings, }, _ => { return Err(windmill_common::error::Error::internal_err(format!( "Unsupported job payload: {:?}", job_payload ))) } }; let (uuid, tx) = push( &db, tx, &workspace_id, retryable_job_payload, push_args, authed.display_username(), email, permissioned_as, authed.token_prefix.as_deref(), authed.username_override.as_deref(), None, None, None, None, None, job_id, false, false, None, true, tag, timeout, None, None, push_authed.as_ref(), false, None, Some(trigger), suspended_mode, ) .await?; // If we were given a transaction, return it; otherwise commit it if return_tx { Ok((uuid, resolved_delete_secs, Some(tx))) } else { tx.commit().await?; Ok((uuid, resolved_delete_secs, None)) } }