diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index d2ab9b41a0..886f83d9fc 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -45,6 +45,7 @@ use windmill_common::scripts::ScriptRunnableSettingsHandle; use windmill_common::utils::require_admin; use windmill_common::variables::decrypt; use windmill_common::worker::WINDMILL_DIR; +use windmill_common::workspaces::WM_FORK_PREFIX; use windmill_common::{ db::UserDB, error::{to_anyhow, Error, Result}, @@ -119,6 +120,26 @@ pub fn is_none_or_false(val: &Option) -> bool { } } +/// Returns the keys to strip from trigger/schedule serialization when the +/// source workspace is a fork. Stripping these keys avoids propagating +/// fork-local operational state (enabled flag, runtime listener identifiers) +/// back to the parent workspace through the git-sync round-trip. +fn fork_trigger_ignore_keys(w_id: &str) -> Option> { + if w_id.starts_with(WM_FORK_PREFIX) { + Some(vec!["mode", "enabled"]) + } else { + None + } +} + +fn fork_schedule_ignore_keys(w_id: &str) -> Option> { + if w_id.starts_with(WM_FORK_PREFIX) { + Some(vec!["enabled"]) + } else { + None + } +} + enum ArchiveImpl { #[cfg(feature = "zip")] Zip(async_zip::tokio::write::ZipFileWriter), @@ -682,8 +703,11 @@ pub(crate) async fn tarball_workspace( .fetch_all(&mut *tx) .await?; + let schedule_ignore_keys = fork_schedule_ignore_keys(&w_id); for schedule in schedules { - let app_str = &to_string_without_metadata(&schedule, false, None).unwrap(); + let app_str = + &to_string_without_metadata(&schedule, false, schedule_ignore_keys.clone()) + .unwrap(); archive .write_to_archive(&app_str, &format!("{}.schedule.json", schedule.path)) .await?; @@ -691,6 +715,27 @@ pub(crate) async fn tarball_workspace( } if include_triggers.unwrap_or(false) { + #[cfg(any( + feature = "http_trigger", + feature = "websocket", + feature = "postgres_trigger", + feature = "mqtt_trigger", + feature = "native_trigger", + all( + feature = "enterprise", + any( + feature = "kafka", + feature = "sqs_trigger", + feature = "gcp_trigger", + feature = "azure_trigger", + feature = "nats", + feature = "smtp", + ), + feature = "private" + ) + ))] + let trigger_ignore_keys = fork_trigger_ignore_keys(&w_id); + #[cfg(feature = "http_trigger")] { use crate::triggers::http::HttpTrigger; @@ -698,7 +743,9 @@ pub(crate) async fn tarball_workspace( let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in http_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -715,7 +762,9 @@ pub(crate) async fn tarball_workspace( let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in websocket_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -732,7 +781,9 @@ pub(crate) async fn tarball_workspace( let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in kafka_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -749,7 +800,9 @@ pub(crate) async fn tarball_workspace( let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in sqs_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -766,7 +819,9 @@ pub(crate) async fn tarball_workspace( let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in gcp_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -783,7 +838,9 @@ pub(crate) async fn tarball_workspace( let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in azure_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -801,7 +858,8 @@ pub(crate) async fn tarball_workspace( for trigger in nats_triggers { let trigger_str: &String = - &to_string_without_metadata(&trigger, false, None).unwrap(); + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -818,7 +876,9 @@ pub(crate) async fn tarball_workspace( let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in postgres_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -835,7 +895,9 @@ pub(crate) async fn tarball_workspace( let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in mqtt_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -852,7 +914,9 @@ pub(crate) async fn tarball_workspace( let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in email_triggers { - let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap(); + let trigger_str = + &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -872,11 +936,16 @@ pub(crate) async fn tarball_workspace( list_native_triggers(&mut *tx, &w_id, service_name, None, None, None, None) .await?; + let mut native_ignore_keys = vec!["webhook_token_hash"]; + if let Some(ref extra) = trigger_ignore_keys { + native_ignore_keys.extend_from_slice(extra); + } + for trigger in native_triggers { let trigger_str = &to_string_without_metadata( &trigger, false, - Some(vec!["webhook_token_hash"]), + Some(native_ignore_keys.clone()), ) .unwrap(); archive diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 3ac9281fd4..d872636c8a 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -557,7 +557,7 @@ async fn update_trigger( Extension(db): Extension, Extension(user_db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, - Json(edit_trigger): Json>, + Json(mut edit_trigger): Json>, ) -> Result { let path = path.to_path(); check_scopes(&authed, || { @@ -574,6 +574,24 @@ async fn update_trigger( let mut tx = user_db.begin(&authed).await?; + // When the request omits `mode`/`enabled`, preserve the existing DB value + // instead of falling back to the BaseTriggerData default (Enabled). This + // keeps fork→parent git-sync round-trips from flipping the parent's + // operational state — see fork_trigger_ignore_keys in workspaces_export.rs. + if 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( diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index 84f43547c9..8b42c5b1f9 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -128,6 +128,21 @@ impl BaseTriggerData { ) } + /// True when neither `mode` nor the legacy `enabled` field was provided in + /// the request. Used by the update path to distinguish "explicitly Enabled" + /// from "missing — preserve existing value", which matters for git-sync + /// round-trips through fork workspaces (see workspaces_export.rs). + pub fn is_mode_unspecified(&self) -> bool { + #[allow(deprecated)] + { + self.mode.is_none() && self.enabled.is_none() + } + } + + pub fn set_mode(&mut self, mode: TriggerMode) { + self.mode = Some(mode); + } + pub fn resolve_permissioned_as(&self, authed: &impl Authable) -> String { if let Some(ref permissioned_as) = self.permissioned_as { if self.preserve_permissioned_as.unwrap_or(false)