/* * Author: Windmill Labs, Inc * Copyright: Windmill Labs, Inc 2024 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ use crate::types::{StandardTriggerQuery, TriggerData, TriggerMode}; use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sql_builder::{bind::Bind, SqlBuilder}; use sqlx::{FromRow, PgConnection}; use std::fmt::Debug; use windmill_api_auth::{check_scopes, ApiAuthed}; use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, user_drafts::{ delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only_list_rows, overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, }, utils::{paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, }; use windmill_git_sync::DeployedObject; use axum::{ extract::{Path, Query}, http::StatusCode, routing::{delete, get, post}, Extension, Json, Router, }; use std::sync::Arc; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_git_sync::handle_deployment_metadata; /// True when the workspace is a fork (`parent_workspace_id IS NOT NULL`). /// /// Operational state (`mode`) belongs to the parent workspace: a git-sync / /// merge / clone / UI-create write into a fork must never set it. On create we /// force `disabled` so a fork trigger can't compete with the parent's listener; /// on update we preserve the fork's existing value. `setmode` is the intended /// explicit mutator of a fork's mode (and carries its own conflict warning) — /// runtime error handling may still auto-disable an errored trigger, which is /// orthogonal to this rule. This is the write half whose read half lives in /// `workspaces_export.rs` (parent-value substitution on fork export), and it is /// the single authority shared by both the git-sync round-trip and the in-app /// compare-workspaces merge. async fn workspace_is_fork(db: &DB, workspace_id: &str) -> Result { let is_fork: Option = sqlx::query_scalar("SELECT parent_workspace_id IS NOT NULL FROM workspace WHERE id = $1") .bind(workspace_id) .fetch_optional(db) .await?; Ok(is_fork.unwrap_or(false)) } #[async_trait] pub trait TriggerCrud: Send + Sync + 'static { type Trigger: Serialize + DeserializeOwned + for<'r> FromRow<'r, sqlx::postgres::PgRow> + Send + Sync + Unpin // `'static` so the deployed trigger can be boxed into // `WithDraftOverlay`'s erased-serde inner (it's an owned row). + 'static; type TriggerConfig: Debug + DeserializeOwned + for<'r> FromRow<'r, sqlx::postgres::PgRow> + Serialize + Send + Sync + Unpin; type TriggerConfigRequest: Debug + DeserializeOwned + Serialize + Send + Sync; type TestConnectionConfig: Debug + DeserializeOwned + Serialize + Send + Sync; /// Table name used in dynamic SQL queries via `format!()`. This is a compile-time /// constant set by each trigger impl — it is never user-controllable. const TABLE_NAME: &'static str; const TRIGGER_TYPE: &'static str; /// `UserDraftItemKind` for this trigger's per-user `draft` rows. Required (no /// default) so a trigger that forgets it is a compile error, not a runtime panic. const DRAFT_KIND: UserDraftItemKind; const SUPPORTS_SERVER_STATE: bool; const SUPPORTS_TEST_CONNECTION: bool; const ROUTE_PREFIX: &'static str; const DEPLOYMENT_NAME: &'static str; const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[]; const IS_ALLOWED_ON_CLOUD: bool; /// Whether enabling this trigger in a fork while the parent has the same /// path enabled is a real conflict (shared upstream resource). True for /// listener-based kinds where two consumers compete (Kafka group, PG slot, /// SQS queue, etc.) and for Websocket where both subscribers fire on every /// broadcast. False for kinds whose upstream identifier is implicitly /// workspace-scoped at runtime (HTTP routes, Email local_part — clones for /// the non-workspaced sub-case are filtered out, so any cloned row is /// already collision-free vs. the parent). const FORK_CONFLICT_ON_ENABLE: bool = true; fn get_deployed_object(path: String, parent_path: Option) -> DeployedObject; async fn validate_new( &self, db: &DB, workspace_id: &str, new: &Self::TriggerConfigRequest, ) -> Result<()> { self.validate_config(db, new, workspace_id).await } async fn validate_edit( &self, db: &DB, workspace_id: &str, edit: &Self::TriggerConfigRequest, _path: &str, ) -> Result<()> { self.validate_config(db, edit, workspace_id).await } async fn validate_config( &self, _db: &DB, _config: &Self::TriggerConfigRequest, _workspace_id: &str, ) -> Result<()> { Ok(()) } fn scope_domain_name() -> &'static str { &Self::ROUTE_PREFIX[1..] } /// Accessor for `DRAFT_KIND` used at the draft-lookup call sites. fn user_draft_item_kind() -> UserDraftItemKind { Self::DRAFT_KIND } async fn create_trigger( &self, db: &DB, tx: &mut PgConnection, authed: &ApiAuthed, w_id: &str, trigger: TriggerData, ) -> Result<()>; async fn update_trigger( &self, db: &DB, tx: &mut PgConnection, authed: &ApiAuthed, workspace_id: &str, path: &str, trigger: TriggerData, ) -> Result<()>; async fn test_connection( &self, _db: &DB, _authed: &ApiAuthed, _user_db: &UserDB, _workspace_id: &str, _config: Self::TestConnectionConfig, ) -> Result<()> { Err( anyhow::anyhow!("Test connection not supported for this trigger type".to_string(),) .into(), ) } fn additional_routes(&self) -> axum::Router { axum::Router::new() } async fn get_trigger_by_path( &self, tx: &mut PgConnection, workspace_id: &str, path: &str, ) -> Result { let mut fields = vec![ "workspace_id", "path", "script_path", "is_flow", "edited_by", "permissioned_as", "edited_at", "extra_perms", "mode", "labels", ]; if Self::SUPPORTS_SERVER_STATE { fields.extend_from_slice(&["server_id", "last_server_ping", "error"]); } fields.extend_from_slice(&["error_handler_path", "error_handler_args", "retry"]); fields.extend_from_slice(Self::ADDITIONAL_SELECT_FIELDS); let sql = format!( r#"SELECT {} FROM {} WHERE workspace_id = $1 AND path = $2 "#, fields.join(", "), Self::TABLE_NAME ); sqlx::query_as(&sql) .bind(workspace_id) .bind(path) .fetch_optional(&mut *tx) .await? .ok_or_else(|| Error::NotFound(format!("Trigger not found at path: {}", path))) } async fn exists(&self, db: &DB, workspace_id: &str, path: &str) -> Result { // SAFETY: Self::TABLE_NAME is a compile-time constant, not user input. let exists = sqlx::query_scalar(&format!( "SELECT EXISTS(SELECT 1 FROM {} WHERE workspace_id = $1 AND path = $2)", Self::TABLE_NAME )) .bind(workspace_id) .bind(path) .fetch_one(db) .await?; Ok(exists) } async fn delete_by_path( &self, tx: &mut PgConnection, workspace_id: &str, path: &str, ) -> Result { // SAFETY: Self::TABLE_NAME is a compile-time constant, not user input. let deleted = sqlx::query(&format!( "DELETE FROM {} WHERE workspace_id = $1 AND path = $2", Self::TABLE_NAME )) .bind(workspace_id) .bind(path) .execute(&mut *tx) .await? .rows_affected(); Ok(deleted > 0) } async fn set_trigger_mode_extra_action(&self, _: &mut PgConnection) -> Result<()> { Ok(()) } async fn set_trigger_mode( &self, authed: &ApiAuthed, tx: &mut PgConnection, workspace_id: &str, path: &str, mode: &TriggerMode, ) -> Result { let permissioned_as = windmill_common::users::username_to_permissioned_as(&authed.username); let updated = if Self::SUPPORTS_SERVER_STATE { // SAFETY: Self::TABLE_NAME is a compile-time constant. sqlx::query(&format!( r#" UPDATE {} SET mode = $1, permissioned_as = $2, edited_by = $3, edited_at = now(), server_id = NULL, error = NULL WHERE workspace_id = $4 AND path = $5 "#, Self::TABLE_NAME )) .bind(mode) .bind(&permissioned_as) .bind(&authed.username) .bind(workspace_id) .bind(path) .execute(&mut *tx) .await? .rows_affected() } else { // SAFETY: Self::TABLE_NAME is a compile-time constant. sqlx::query(&format!( r#" UPDATE {} SET mode = $1, permissioned_as = $2, edited_by = $3, edited_at = now() WHERE workspace_id = $4 AND path = $5 "#, Self::TABLE_NAME )) .bind(mode) .bind(&permissioned_as) .bind(&authed.username) .bind(workspace_id) .bind(path) .execute(&mut *tx) .await? .rows_affected() }; self.set_trigger_mode_extra_action(&mut *tx).await?; Ok(updated > 0) } #[allow(unused)] async fn trigger_count( &self, tx: &mut PgConnection, workspace_id: &str, is_flow: bool, script_path: &str, ) -> i64 { // SAFETY: Self::TABLE_NAME is a compile-time constant. let count = sqlx::query_scalar(&format!( r#" SELECT COUNT(*) FROM {} WHERE workspace_id = $1 AND is_flow = $2 AND script_path = $3 "#, Self::TABLE_NAME )) .bind(workspace_id) .bind(is_flow) .bind(script_path) .fetch_one(&mut *tx) .await .unwrap_or(0); count } /// `authed_email = Some` adds the per-user `is_draft` flag (scalar EXISTS); /// `None` (e.g. workspace export) leaves it omitted. async fn list_triggers( &self, tx: &mut PgConnection, workspace_id: &str, query: Option<&StandardTriggerQuery>, authed_email: Option<&str>, ) -> Result> { let mut fields = vec![ "workspace_id", "path", "script_path", "is_flow", "edited_by", "permissioned_as", "edited_at", "extra_perms", "mode", "labels", ]; if Self::SUPPORTS_SERVER_STATE { fields.extend_from_slice(&["server_id", "last_server_ping", "error"]); } fields.extend_from_slice(&["error_handler_path", "error_handler_args", "retry"]); fields.extend_from_slice(Self::ADDITIONAL_SELECT_FIELDS); let mut sqlb = SqlBuilder::select_from(Self::TABLE_NAME); sqlb.fields(&fields) .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&workspace_id)); if let Some(email) = authed_email { // SAFETY: interpolated TABLE_NAME and draft kind are compile-time constants; email is bound. sqlb.field( &format!( "EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = {t}.workspace_id \ AND draft.path = {t}.path AND draft.typ = '{k}' AND draft.email = ?) as is_draft", t = Self::TABLE_NAME, k = Self::user_draft_item_kind().as_str(), ) .bind(&email), ); } if let Some(query) = query { let (per_page, offset) = paginate(Pagination { per_page: query.per_page, page: query.page }); if let Some(path) = &query.path { sqlb.and_where_eq("script_path", "?".bind(path)); } if let Some(is_flow) = query.is_flow { sqlb.and_where_eq("is_flow", "?".bind(&is_flow)); } if let Some(path_start) = &query.path_start { sqlb.and_where_like_left("path", path_start); } if let Some(label) = &query.label { for l in label.split(',') { sqlb.and_where("labels @> ARRAY[?]".bind(&l.trim())); } } sqlb.offset(offset).limit(per_page); } let sql = sqlb .sql() .map_err(|e| Error::InternalErr(format!("SQL error: {}", e)))?; let triggers = sqlx::query_as(&sql).fetch_all(&mut *tx).await?; Ok(triggers) } } pub fn trigger_routes() -> Router { let mut router = Router::new() .route("/create", post(create_trigger::)) .route("/list", get(list_triggers::)) .route("/get/{*path}", get(get_trigger::)) .route("/update/{*path}", post(update_trigger::)) .route("/delete/{*path}", delete(delete_trigger::)) .route("/exists/{*path}", get(exists_trigger::)) .route("/setmode/{*path}", post(set_trigger_mode::)); if T::SUPPORTS_TEST_CONNECTION { router = router.route("/test", post(test_connection::)); } router } async fn create_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Path(workspace_id): Path, Json(mut new_trigger): Json>, ) -> Result<(StatusCode, String)> { check_scopes(&authed, || { format!( "{}:write:{}", T::scope_domain_name(), &new_trigger.base.path ) })?; if *CLOUD_HOSTED && !T::IS_ALLOWED_ON_CLOUD { return Err(Error::BadRequest(format!( "{} triggers are not supported on multi-tenant cloud, use dedicated cloud or self-host", T::TRIGGER_TYPE ))); } handler .validate_new(&db, &workspace_id, &new_trigger.config) .await?; let mut tx = user_db.begin(&authed).await?; // Writing into a fork never sets operational state: force `disabled` so a // cloned / synced / merged / UI-created trigger can't compete with the // parent's listener. The fork owner re-enables locally via `setmode`. if workspace_is_fork(&db, &workspace_id).await? { new_trigger.base.set_mode(TriggerMode::Disabled); } let new_path = new_trigger.base.path.clone(); let labels = new_trigger.base.labels.clone(); // If the caller did not preserve a value but the user can preserve, fall back // to the folder's default_permissioned_as rule (create-time only). let explicit_preserve = new_trigger.base.permissioned_as.is_some() && new_trigger.base.preserve_permissioned_as.unwrap_or(false) && windmill_common::can_preserve_on_behalf_of(&authed); if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) { if let Some(default) = windmill_common::folders::resolve_folder_default_permissioned_as( &db, &workspace_id, &new_trigger.base.path, ) .await? { new_trigger.base.permissioned_as = Some(default); new_trigger.base.preserve_permissioned_as = Some(true); } } let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( new_trigger.base.permissioned_as.as_deref(), new_trigger.base.preserve_permissioned_as.unwrap_or(false), &authed, &authed.username, ); handler .create_trigger(&db, &mut *tx, &authed, &workspace_id, new_trigger) .await?; if let Some(ref labels) = labels { // SAFETY: T::TABLE_NAME is a compile-time constant. sqlx::query(&format!( "UPDATE {} SET labels = $1 WHERE workspace_id = $2 AND path = $3", T::TABLE_NAME )) .bind(labels) .bind(&workspace_id) .bind(&new_path) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, &format!("{}_triggers.create", T::TRIGGER_TYPE), ActionKind::Create, &workspace_id, Some(&new_path), None, ) .await?; if let Some(on_behalf_of) = on_behalf_of_info { audit_log( &mut *tx, &authed, &format!("{}_triggers.on_behalf_of", T::TRIGGER_TYPE), ActionKind::Create, &workspace_id, Some(&new_path), Some( [ ("on_behalf_of", on_behalf_of.as_str()), ("action", "create"), ] .into(), ), ) .await?; } handle_deployment_metadata( &authed.email, &authed.username, &db, &workspace_id, T::get_deployed_object(new_path.clone(), None), Some(format!("{} '{}' created", T::DEPLOYMENT_NAME, new_path)), true, None, ) .await?; tx.commit().await?; Ok((StatusCode::CREATED, new_path)) } async fn list_triggers( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path(workspace_id): Path, Query(query): Query, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; let mut triggers = handler .list_triggers(&mut *tx, &workspace_id, Some(&query), Some(&authed.email)) .await?; tx.commit().await?; // Append the authed user's draft-only triggers of this kind; see scripts.rs. // Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind // config can deviate, so drop a row on deserialize failure rather than fail the list. if query.include_draft_only.unwrap_or(false) && !authed.is_operator && query.page.unwrap_or(0) == 0 && query.path.is_none() && query.is_flow.is_none() && query.path_start.is_none() && query.label.is_none() { let draft_only_rows = fetch_draft_only_list_rows( &db, &workspace_id, &authed.email, T::user_draft_item_kind(), ) .await?; for row in draft_only_rows { let created_at = row.created_at; let v: serde_json::Value = match serde_json::from_str(row.value.0.get()) { Ok(v) => v, Err(_) => continue, }; let serde_json::Value::Object(mut map) = v else { continue; }; // Fill operational fields the editor draft omits so the merged JSON matches // `Trigger`'s flattened shape (mode derived from `enabled`). map.insert( "workspace_id".into(), serde_json::Value::String(workspace_id.clone()), ); map.insert("edited_by".into(), serde_json::Value::String(String::new())); if let Ok(at) = serde_json::to_value(&created_at) { map.insert("edited_at".into(), at); } map.entry("permissioned_as") .or_insert(serde_json::Value::String(String::new())); map.entry("extra_perms").or_insert(serde_json::Value::Null); if !map.contains_key("mode") { let enabled = map.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); map.insert( "mode".into(), serde_json::Value::String(if enabled { "enabled" } else { "disabled" }.into()), ); } map.insert("draft_only".into(), serde_json::Value::Bool(true)); // Synthesized rows are the authed user's draft. map.insert("is_draft".into(), serde_json::Value::Bool(true)); match serde_json::from_value::(serde_json::Value::Object(map)) { Ok(t) => triggers.push(t), Err(_) => continue, } } } Ok(Json(triggers)) } async fn get_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, Query(q): Query, ) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || { format!("{}:read:{}", T::scope_domain_name(), &path) })?; let mut tx = user_db.begin(&authed).await?; let trigger_res = handler .get_trigger_by_path(&mut *tx, &workspace_id, path) .await; tx.commit().await?; // Map "no deployed trigger" to `None` and let the shared choreography // handle the draft overlay / draft-only fallback / 404. let deployed = match trigger_res { Ok(t) => Some(t), Err(Error::NotFound(_)) => None, Err(e) => return Err(e), }; let overlay = overlay_or_draft_only( &db, &workspace_id, &authed.email, T::user_draft_item_kind(), path, q.get_draft, deployed, || Error::NotFound(format!("Trigger not found at path: {}", path)), ) .await?; Ok(Json(overlay)) } async fn update_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, Json(mut edit_trigger): Json>, ) -> Result { let path = path.to_path(); check_scopes(&authed, || { format!( "{}:write:{}", T::scope_domain_name(), &edit_trigger.base.path ) })?; handler .validate_edit(&db, &workspace_id, &edit_trigger.config, path) .await?; let mut tx = user_db.begin(&authed).await?; // Preserve the existing DB `mode` instead of writing the incoming value // when either: // * the target is a fork — a fork's operational state is fork-local and // is never set through a git-sync/merge write (only via `setmode`); or // * the request omits `mode`/`enabled` (legacy clients / YAML round-trip), // where falling back to the BaseTriggerData default (Enabled) would flip // the parent on a fork→parent merge. // Read half of the rule: parent-value substitution on fork export in // workspaces_export.rs. if workspace_is_fork(&db, &workspace_id).await? || edit_trigger.base.is_mode_unspecified() { let existing_mode: Option = sqlx::query_scalar(&format!( "SELECT mode FROM {} WHERE workspace_id = $1 AND path = $2", T::TABLE_NAME )) .bind(&workspace_id) .bind(path) .fetch_optional(&mut *tx) .await?; if let Some(m) = existing_mode { edit_trigger.base.set_mode(m); } } let new_path = edit_trigger.base.path.to_string(); let labels = edit_trigger.base.labels.clone(); let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( edit_trigger.base.permissioned_as.as_deref(), edit_trigger.base.preserve_permissioned_as.unwrap_or(false), &authed, &authed.username, ); handler .update_trigger(&db, &mut *tx, &authed, &workspace_id, path, edit_trigger) .await?; if let Some(ref labels) = labels { // SAFETY: T::TABLE_NAME is a compile-time constant. sqlx::query(&format!( "UPDATE {} SET labels = $1 WHERE workspace_id = $2 AND path = $3", T::TABLE_NAME )) .bind(labels) .bind(&workspace_id) .bind(&new_path) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, &format!("{}_triggers.update", T::TRIGGER_TYPE), ActionKind::Update, &workspace_id, Some(&new_path), None, ) .await?; if let Some(on_behalf_of) = on_behalf_of_info { audit_log( &mut *tx, &authed, &format!("{}_triggers.on_behalf_of", T::TRIGGER_TYPE), ActionKind::Update, &workspace_id, Some(&new_path), Some( [ ("on_behalf_of", on_behalf_of.as_str()), ("action", "update"), ] .into(), ), ) .await?; } let parent_path = if path != new_path { Some(path.to_string()) } else { None }; handle_deployment_metadata( &authed.email, &authed.username, &db, &workspace_id, T::get_deployed_object(new_path.clone(), parent_path), Some(format!("{} '{}' updated", T::DEPLOYMENT_NAME, new_path)), true, None, ) .await?; tx.commit().await?; // On rename the old-path draft orphans (no SQL FK); clear the deployer's own // (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). See scripts.rs. if path != new_path { delete_own_draft_for_path( &db, &workspace_id, T::user_draft_item_kind(), path, &authed.email, ) .await?; } Ok(format!("Trigger '{}' updated", path)) } async fn delete_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, ) -> Result { let path = path.to_path(); check_scopes(&authed, || { format!("{}:write:{}", T::scope_domain_name(), &path) })?; let mut tx = user_db.begin(&authed).await?; // Capture trigger data for trashbin before deleting // SAFETY: T::TABLE_NAME is a compile-time constant. let trash_data: Option = sqlx::query_scalar(&format!( "SELECT jsonb_build_object('row', to_jsonb(t), 'table_name', '{table}') FROM {table} t WHERE path = $1 AND workspace_id = $2", table = T::TABLE_NAME )) .bind(path) .bind(&workspace_id) .fetch_optional(&mut *tx) .await?; let deleted = handler .delete_by_path(&mut *tx, &workspace_id, path) .await?; if !deleted { return Err(Error::NotFound(format!( "Trigger not found at path: {}", path ))); } if let Some(data) = trash_data { let item_kind = format!("{}_trigger", T::TRIGGER_TYPE); windmill_common::trashbin::move_to_trash( &mut *tx, &workspace_id, &item_kind, path, data, &authed.username, ) .await?; } audit_log( &mut *tx, &authed, &format!("{}_triggers.delete", T::TRIGGER_TYPE), ActionKind::Delete, &workspace_id, Some(&path), None, ) .await?; tx.commit().await?; // Reset the fork/parent workspace_diff tally for this path, exactly as // create/update and every other kind's delete does. Without this a deleted // trigger leaves its cached `has_changes=true` diff row behind: the compare // trusts it (triggers aren't re-validated like scripts/flows), then drops it // as it no longer exists in the table — a phantom "ahead" item that reads as // "changes not visible to your user" and hides the deploy button, even for // superadmins. Re-tallying sets has_changes=NULL so the next compare // re-evaluates and corrects/removes the row. handle_deployment_metadata( &authed.email, &authed.username, &db, &workspace_id, T::get_deployed_object(path.to_string(), None), Some(format!("{} '{}' deleted", T::DEPLOYMENT_NAME, path)), true, None, ) .await?; // Trigger gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. delete_all_drafts_for_path(&db, &workspace_id, T::user_draft_item_kind(), path).await?; Ok(format!("Trigger '{}' deleted", path)) } async fn exists_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, ) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || { format!("{}:read:{}", T::scope_domain_name(), path) })?; let exists = handler.exists(&db, &workspace_id, path).await?; Ok(Json(exists)) } #[derive(serde::Deserialize)] struct SetTriggerModePayload { mode: TriggerMode, /// When true, bypass the parent-state warning that would otherwise reject /// enabling a trigger that's already enabled in the parent workspace. /// The frontend sets this after the user confirms the duplicate-execution /// dialog. See windmill-trigger/src/handler.rs::set_trigger_mode for the /// full check. #[serde(default)] force: bool, } /// Returns the parent workspace id when this workspace is a fork *and* the /// parent has a row at the same trigger path. Used to gate enabling a trigger /// in a fork behind an explicit `force=true` confirmation: the fork's row was /// cloned from the parent, so its upstream identifier (Kafka group, PG slot, /// SQS queue URL, etc.) is shared by construction. The risk is independent of /// the parent's current `mode`: if the parent is enabled, the two listeners /// compete; if it's disabled, the fork can destructively take over shared /// state (e.g. advance the PG WAL, claim an MQTT client_id) before the parent /// re-enables. Either way, the user should be asked to confirm. async fn parent_has_trigger( tx: &mut PgConnection, table_name: &str, workspace_id: &str, path: &str, ) -> Result> { let parent: Option = sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1") .bind(workspace_id) .fetch_optional(&mut *tx) .await? .flatten(); let Some(parent_id) = parent else { return Ok(None); }; let exists: Option = sqlx::query_scalar(&format!( "SELECT EXISTS(SELECT 1 FROM {} WHERE workspace_id = $1 AND path = $2)", table_name )) .bind(&parent_id) .bind(path) .fetch_one(&mut *tx) .await?; Ok(if exists == Some(true) { Some(parent_id) } else { None }) } async fn set_trigger_mode( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, Json(payload): Json, ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("{}:write", T::scope_domain_name()))?; let mut tx = user_db.begin(&authed).await?; // Block transitioning a trigger in a fork to any mode that attaches a // listener (Enabled or Suspended) when the parent has the same path, // unless the caller passes force=true. Suspended still keeps the // listener attached — it just stops auto-running queued jobs — so a // suspended fork would still split Kafka events / share a PG slot // with the parent. The cloned upstream identifier is shared by // construction; the risk is independent of the parent's current mode. // Skipped for kinds where the upstream identifier is already // workspace-scoped at runtime (HTTP, Email). if T::FORK_CONFLICT_ON_ENABLE && payload.mode != TriggerMode::Disabled && !payload.force { if let Some(parent_id) = parent_has_trigger(&mut *tx, T::TABLE_NAME, &workspace_id, path).await? { return Err(Error::BadRequest(format!( "fork-conflict:{}:{}", T::TRIGGER_TYPE, parent_id ))); } } let updated = handler .set_trigger_mode(&authed, &mut *tx, &workspace_id, path, &payload.mode) .await?; if !updated { return Err(Error::NotFound(format!( "Trigger not found at path: {}", path ))); } tx.commit().await?; handle_deployment_metadata( &authed.email, &authed.username, &db, &workspace_id, T::get_deployed_object(path.to_owned(), None), Some(format!("{} trigger '{}' updated", T::DEPLOYMENT_NAME, path)), true, None, ) .await?; Ok(format!( "Trigger '{}' {}", path, if payload.mode == TriggerMode::Enabled { "enabled" } else if payload.mode == TriggerMode::Disabled { "disabled" } else { "suspended" } )) } async fn test_connection( Extension(handler): Extension>, authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Path(workspace_id): Path, Json(config): Json, ) -> Result<()> { // Test connection opens an outbound connection to a caller-supplied target, // so gate it behind write access like the other mutating trigger routes. check_scopes(&authed, || format!("{}:write", T::scope_domain_name()))?; let connect_f = async move { handler .test_connection(&db, &authed, &user_db, &workspace_id, config) .await }; tokio::time::timeout(tokio::time::Duration::from_secs(30), connect_f) .await .map_err(|_| { Error::BadConfig(format!("Timeout connecting to service after 30 seconds")) })??; Ok(()) } #[allow(unused)] pub fn complete_trigger_routes(handler: T) -> Router { let standard_routes = trigger_routes::(); let additional_routes = handler.additional_routes(); standard_routes .merge(additional_routes) .layer(Extension(Arc::new(handler))) } #[derive(Serialize, Deserialize, Debug)] pub struct TriggerPrimarySchedule { pub schedule: String, } // generate_trigger_routers(), get_triggers_count_internal(), TriggersCount stay in windmill-api