diff --git a/backend/migrations/20260429163532_add_workspace_fork_triggers.down.sql b/backend/migrations/20260429163532_add_workspace_fork_triggers.down.sql new file mode 100644 index 0000000000..718701116d --- /dev/null +++ b/backend/migrations/20260429163532_add_workspace_fork_triggers.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace DROP COLUMN fork_triggers; diff --git a/backend/migrations/20260429163532_add_workspace_fork_triggers.up.sql b/backend/migrations/20260429163532_add_workspace_fork_triggers.up.sql new file mode 100644 index 0000000000..00ccffbb6d --- /dev/null +++ b/backend/migrations/20260429163532_add_workspace_fork_triggers.up.sql @@ -0,0 +1,4 @@ +-- Tracks whether triggers and schedules were cloned (always disabled) at fork +-- creation time. Read by the create_fork handler; reflected in the UI so users +-- can tell which forks carry the parent's trigger/schedule definitions. +ALTER TABLE workspace ADD COLUMN fork_triggers BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index cd9ce277c5..8de05483f2 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -401,6 +401,12 @@ struct CreateWorkspaceFork { /// forked workspace's datatable config to point to the new database. #[serde(default)] forked_datatables: Vec, + /// When true, clone every trigger and schedule from the parent workspace + /// (always with mode='disabled'/enabled=false). The user re-enables what + /// they need; warnings on enable cover the parent-conflict case. When + /// false (default), the fork starts trigger-empty — same as today. + #[serde(default)] + fork_triggers: bool, } #[derive(Deserialize)] @@ -3790,6 +3796,243 @@ async fn clone_workspace_data( Ok(()) } +/// Clone every trigger and schedule from the parent workspace, forcing +/// `mode='disabled'` / `enabled=false`. Called only when the fork-creation +/// request opts in via `fork_triggers=true`. Listener identifiers +/// (group_id, replication_slot_name, subscription_name, …) are copied +/// verbatim — the runtime suffix that prevents the fork from competing with +/// the parent ships in a follow-up PR (see Phase 3). +async fn clone_triggers_and_schedules( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, +) -> Result<()> { + sqlx::query!( + r#"INSERT INTO schedule ( + workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, + args, extra_perms, is_flow, email, error, timezone, on_failure, + on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args, + on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry, + summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args, + cron_version, description, dynamic_skip, permissioned_as, labels + ) + SELECT + $1, path, edited_by, edited_at, schedule, FALSE, script_path, + args, extra_perms, is_flow, email, error, timezone, on_failure, + on_recovery, on_failure_times, on_failure_exact, on_failure_extra_args, + on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry, + summary, no_flow_overlap, tag, paused_until, on_success, on_success_extra_args, + cron_version, description, dynamic_skip, permissioned_as, labels + FROM schedule WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO http_trigger ( + path, route_path, route_path_key, script_path, is_flow, workspace_id, + edited_by, edited_at, extra_perms, authentication_method, http_method, + static_asset_config, is_static_website, workspaced_route, wrap_body, + raw_string, authentication_resource_path, summary, description, + error_handler_path, error_handler_args, retry, request_type, mode, + permissioned_as, labels + ) + SELECT + path, route_path, route_path_key, script_path, is_flow, $1, + edited_by, edited_at, extra_perms, authentication_method, http_method, + static_asset_config, is_static_website, workspaced_route, wrap_body, + raw_string, authentication_resource_path, summary, description, + error_handler_path, error_handler_args, retry, request_type, 'disabled'::TRIGGER_MODE, + permissioned_as, labels + FROM http_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO websocket_trigger ( + path, url, script_path, is_flow, workspace_id, edited_by, edited_at, + extra_perms, server_id, last_server_ping, error, filters, initial_messages, + url_runnable_args, can_return_message, error_handler_path, error_handler_args, + retry, can_return_error_result, mode, permissioned_as, filter_logic, labels, + heartbeat + ) + SELECT + path, url, script_path, is_flow, $1, edited_by, edited_at, + extra_perms, NULL, NULL, NULL, filters, initial_messages, + url_runnable_args, can_return_message, error_handler_path, error_handler_args, + retry, can_return_error_result, 'disabled'::TRIGGER_MODE, permissioned_as, filter_logic, labels, + heartbeat + FROM websocket_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO kafka_trigger ( + path, kafka_resource_path, topics, group_id, script_path, is_flow, + workspace_id, edited_by, edited_at, extra_perms, server_id, + last_server_ping, error, error_handler_path, error_handler_args, retry, + mode, filters, auto_offset_reset, reset_offset, auto_commit, + permissioned_as, filter_logic, labels + ) + SELECT + path, kafka_resource_path, topics, group_id, script_path, is_flow, + $1, edited_by, edited_at, extra_perms, NULL, + NULL, NULL, error_handler_path, error_handler_args, retry, + 'disabled'::TRIGGER_MODE, filters, auto_offset_reset, reset_offset, auto_commit, + permissioned_as, filter_logic, labels + FROM kafka_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO nats_trigger ( + path, nats_resource_path, subjects, stream_name, consumer_name, + use_jetstream, script_path, is_flow, workspace_id, edited_by, edited_at, + extra_perms, server_id, last_server_ping, error, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + path, nats_resource_path, subjects, stream_name, consumer_name, + use_jetstream, script_path, is_flow, $1, edited_by, edited_at, + extra_perms, NULL, NULL, NULL, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM nats_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO postgres_trigger ( + path, script_path, is_flow, workspace_id, edited_by, edited_at, + extra_perms, postgres_resource_path, error, server_id, last_server_ping, + replication_slot_name, publication_name, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + path, script_path, is_flow, $1, edited_by, edited_at, + extra_perms, postgres_resource_path, NULL, NULL, NULL, + replication_slot_name, publication_name, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM postgres_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO mqtt_trigger ( + mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config, + client_id, path, script_path, is_flow, workspace_id, edited_by, edited_at, + extra_perms, server_id, last_server_ping, error, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + mqtt_resource_path, subscribe_topics, client_version, v5_config, v3_config, + client_id, path, script_path, is_flow, $1, edited_by, edited_at, + extra_perms, NULL, NULL, NULL, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM mqtt_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO sqs_trigger ( + path, queue_url, aws_resource_path, message_attributes, script_path, + is_flow, workspace_id, edited_by, edited_at, extra_perms, error, + server_id, last_server_ping, aws_auth_resource_type, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + path, queue_url, aws_resource_path, message_attributes, script_path, + is_flow, $1, edited_by, edited_at, extra_perms, NULL, + NULL, NULL, aws_auth_resource_type, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM sqs_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO gcp_trigger ( + gcp_resource_path, topic_id, subscription_id, delivery_type, + delivery_config, path, script_path, is_flow, workspace_id, edited_by, + edited_at, extra_perms, server_id, last_server_ping, error, + subscription_mode, error_handler_path, error_handler_args, retry, + auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels + ) + SELECT + gcp_resource_path, topic_id, subscription_id, delivery_type, + delivery_config, path, script_path, is_flow, $1, edited_by, + edited_at, extra_perms, NULL, NULL, NULL, + subscription_mode, error_handler_path, error_handler_args, retry, + auto_acknowledge_msg, ack_deadline, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM gcp_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO azure_trigger ( + azure_resource_path, azure_mode, scope_resource_id, topic_name, + subscription_name, event_type_filters, push_auth_config, path, script_path, + is_flow, workspace_id, edited_by, email, edited_at, extra_perms, server_id, + last_server_ping, error, mode, permissioned_as, error_handler_path, + error_handler_args, retry, labels + ) + SELECT + azure_resource_path, azure_mode, scope_resource_id, topic_name, + subscription_name, event_type_filters, push_auth_config, path, script_path, + is_flow, $1, edited_by, email, edited_at, extra_perms, NULL, + NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path, + error_handler_args, retry, labels + FROM azure_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + r#"INSERT INTO email_trigger ( + path, local_part, workspaced_local_part, script_path, is_flow, + workspace_id, edited_by, edited_at, extra_perms, error_handler_path, + error_handler_args, retry, mode, permissioned_as, labels + ) + SELECT + path, local_part, workspaced_local_part, script_path, is_flow, + $1, edited_by, edited_at, extra_perms, error_handler_path, + error_handler_args, retry, 'disabled'::TRIGGER_MODE, permissioned_as, labels + FROM email_trigger WHERE workspace_id = $2"#, + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + async fn update_workspace_settings( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -4621,12 +4864,13 @@ async fn create_workspace_fork( sqlx::query!( "INSERT INTO workspace - (id, name, owner, parent_workspace_id) - VALUES ($1, $2, $3, $4)", + (id, name, owner, parent_workspace_id, fork_triggers) + VALUES ($1, $2, $3, $4, $5)", forked_id, nw.name, authed.email, parent_workspace_id, + nw.fork_triggers, ) .execute(&mut *tx) .await?; @@ -4657,6 +4901,13 @@ async fn create_workspace_fork( // Clone all data from the parent workspace using Rust implementation clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?; + // Optionally clone triggers and schedules. Always with mode='disabled' / + // enabled=false — the user re-enables in the fork, with warnings if the + // parent has the same path enabled (see Phase 4). + if nw.fork_triggers { + clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?; + } + // Update forked datatable settings to point to new databases for fdt in &nw.forked_datatables { apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 9daf804796..b821951a39 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -25836,6 +25836,13 @@ components: new_dbname: type: string description: "New database name for the fork" + fork_triggers: + type: boolean + description: > + When true, every trigger and schedule from the parent workspace is + cloned into the fork with mode='disabled' / enabled=false. The user + re-enables in the fork. When false (default), the fork starts + trigger-empty. required: - id - name