From e6e2afa3f4052fa4def428a19500b59e117dc98a Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 29 Apr 2026 18:47:32 +0200 Subject: [PATCH] feat(forks): UI: opt-in clone-triggers checkbox + confirm-on-fork-conflict Adds the user-facing surface for the fork-trigger work: - CreateWorkspaceInner: new "Clone triggers and schedules" toggle in the fork-creation dialog (default off). Sends fork_triggers in the request. - forkConflict utility: detects the `fork-conflict::` error string from the backend, shows a confirm() dialog explaining why the action is blocked, retries with `force: true` if accepted. - Wires withForkConflictRetry into every trigger setMode and the schedule setEnabled call, both in the per-kind editor components and the +page.svelte list views (HTTP, websocket, kafka, NATS, SQS, MQTT, GCP, Azure, Postgres, email, schedule). OpenAPI spec gains the `force` field on each setmode/setenabled body. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/windmill-api/openapi.yaml | 55 +++++++++++++++++++ .../azure/AzureTriggerEditorInner.svelte | 15 +++-- .../email/EmailTriggerEditorInner.svelte | 15 +++-- .../triggers/gcp/GcpTriggerEditorInner.svelte | 15 +++-- .../triggers/http/RouteEditorInner.svelte | 15 +++-- .../kafka/KafkaTriggerEditorInner.svelte | 15 +++-- .../mqtt/MqttTriggerEditorInner.svelte | 15 +++-- .../nats/NatsTriggerEditorInner.svelte | 15 +++-- .../PostgresTriggerEditorInner.svelte | 15 +++-- .../schedules/ScheduleEditorInner.svelte | 15 +++-- .../triggers/sqs/SqsTriggerEditorInner.svelte | 15 +++-- .../WebsocketTriggerEditorInner.svelte | 15 +++-- .../CreateWorkspaceInner.svelte | 13 ++++- frontend/src/lib/utils/forkConflict.ts | 53 ++++++++++++++++++ .../(logged)/azure_triggers/+page.svelte | 15 +++-- .../(logged)/email_triggers/+page.svelte | 15 +++-- .../(root)/(logged)/gcp_triggers/+page.svelte | 15 +++-- .../(logged)/kafka_triggers/+page.svelte | 15 +++-- .../(logged)/mqtt_triggers/+page.svelte | 15 +++-- .../(logged)/nats_triggers/+page.svelte | 15 +++-- .../(logged)/postgres_triggers/+page.svelte | 15 +++-- .../(root)/(logged)/routes/+page.svelte | 15 +++-- .../(root)/(logged)/schedules/+page.svelte | 15 +++-- .../(root)/(logged)/sqs_triggers/+page.svelte | 15 +++-- .../(logged)/websocket_triggers/+page.svelte | 15 +++-- 25 files changed, 340 insertions(+), 111 deletions(-) create mode 100644 frontend/src/lib/utils/forkConflict.ts diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index b821951a39..8f1dc9101d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -13009,6 +13009,11 @@ paths: properties: enabled: type: boolean + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + schedule in a fork whose parent has the same path enabled. required: - enabled @@ -13455,6 +13460,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -13622,6 +13632,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -13822,6 +13837,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -14063,6 +14083,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -14258,6 +14283,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -15046,6 +15076,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -15241,6 +15276,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -15495,6 +15535,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -16041,6 +16086,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -16267,6 +16317,11 @@ paths: properties: mode: $ref: "#/components/schemas/TriggerMode" + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index 87149dea68..f202c652d9 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -5,6 +5,7 @@ import Path from '$lib/components/Path.svelte' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import { @@ -265,11 +266,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await AzureTriggerService.setAzureTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + AzureTriggerService.setAzureTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'Azure trigger' + ) sendUserToast(`${capitalize(newMode)} Azure trigger ${initialPath}`) onUpdate?.(initialPath) } diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 2cde08dc89..6b45c1c5ac 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -15,6 +15,7 @@ } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' @@ -257,11 +258,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await EmailTriggerService.setEmailTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + EmailTriggerService.setEmailTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'email trigger' + ) sendUserToast(`${capitalize(newMode)} email trigger ${initialPath}`) onUpdate(initialPath) diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 87b22f3255..21351be1b6 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -5,6 +5,7 @@ import Path from '$lib/components/Path.svelte' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import { @@ -280,11 +281,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await GcpTriggerService.setGcpTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + GcpTriggerService.setGcpTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'GCP Pub/Sub trigger' + ) sendUserToast(`${capitalize(newMode)} GCP Pub/Sub trigger ${initialPath}`) onUpdate?.(initialPath) diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 00391437f3..4933de3086 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -23,6 +23,7 @@ generateRandomString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2, Pipette, Plus } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' @@ -408,11 +409,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await HttpTriggerService.setHttpTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + HttpTriggerService.setHttpTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'HTTP trigger' + ) sendUserToast(`${capitalize(newMode)} HTTP trigger ${initialPath}`) onUpdate(initialPath) diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index f2bfd02f28..cf32e08301 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -9,6 +9,7 @@ import { KafkaTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2, RotateCcw } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' @@ -318,11 +319,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await KafkaTriggerService.setKafkaTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + KafkaTriggerService.setKafkaTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'Kafka trigger' + ) sendUserToast(`${capitalize(newMode)} Kafka trigger ${initialPath}`) onUpdate?.(initialPath) diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 537d14bef0..bb60857e8a 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -8,6 +8,7 @@ import ScriptPicker from '$lib/components/ScriptPicker.svelte' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' @@ -304,11 +305,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await MqttTriggerService.setMqttTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + MqttTriggerService.setMqttTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'MQTT trigger' + ) sendUserToast(`${capitalize(newMode)} MQTT trigger ${initialPath}`) onUpdate?.(initialPath) diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 135c08b9d8..7d58123925 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -8,6 +8,7 @@ import { NatsTriggerService, type ErrorHandler, type Retry, type TriggerMode } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' @@ -284,11 +285,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await NatsTriggerService.setNatsTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + NatsTriggerService.setNatsTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'NATS trigger' + ) sendUserToast(`${capitalize(newMode)} NATS trigger ${initialPath}`) onUpdate?.(initialPath) diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 4d59b0b68c..1682c2842e 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -15,6 +15,7 @@ } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' @@ -445,11 +446,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await PostgresTriggerService.setPostgresTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + PostgresTriggerService.setPostgresTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'postgres trigger' + ) sendUserToast(`${capitalize(newMode)} postgres trigger ${initialPath}`) onUpdate?.(initialPath) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 85a1ff843d..661f64d094 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -37,6 +37,7 @@ import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte' import { runScheduleNow } from '../scheduled/utils' import { handleConfigChange } from '../utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import TextInput from '$lib/components/text_input/TextInput.svelte' import { twMerge } from 'tailwind-merge' import PermissionedAsLine from '../PermissionedAsLine.svelte' @@ -629,11 +630,15 @@ async function handleToggleEnabled(nEnabled: boolean) { enabled = nEnabled if (!trigger?.draftConfig) { - await ScheduleService.setScheduleEnabled({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { enabled: nEnabled } - }) + await withForkConflictRetry( + (force) => + ScheduleService.setScheduleEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: nEnabled, force } + }), + 'schedule' + ) sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${initialPath}`) onUpdate?.(initialPath) } diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 09f7782e1d..ee12536236 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -5,6 +5,7 @@ import Path from '$lib/components/Path.svelte' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import { @@ -242,11 +243,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await SqsTriggerService.setSqsTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + SqsTriggerService.setSqsTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'SQS trigger' + ) sendUserToast(`${capitalize(newMode)} SQS trigger ${initialPath}`) onUpdate?.(initialPath) diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index ce807c7e3c..ba90c7ea56 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -21,6 +21,7 @@ } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptySchema, emptyString, sendUserToast } from '$lib/utils' + import { withForkConflictRetry } from '$lib/utils/forkConflict' import Section from '$lib/components/Section.svelte' import { Loader2, X, Plus } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' @@ -372,11 +373,15 @@ async function handleToggleMode(newMode: TriggerMode) { mode = newMode if (!trigger?.draftConfig) { - await WebsocketTriggerService.setWebsocketTriggerMode({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { mode: newMode } - }) + await withForkConflictRetry( + (force) => + WebsocketTriggerService.setWebsocketTriggerMode({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { mode: newMode, force } + }), + 'websocket trigger' + ) sendUserToast(`${capitalize(newMode)} websocket trigger ${initialPath}`) onUpdate?.(initialPath) diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte index e1f5d125bc..2fc26522ff 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte @@ -56,6 +56,7 @@ let workspaceColor: string | undefined = $state(undefined) let colorEnabled = $state(false) + let forkTriggers = $state(false) function generateRandomColor() { const randomColor = @@ -208,7 +209,8 @@ id: prefixed_id, name, color: colorEnabled && workspaceColor ? workspaceColor : undefined, - forked_datatables: forkedDatatables + forked_datatables: forkedDatatables, + fork_triggers: forkTriggers } }) } catch (e) { @@ -507,6 +509,15 @@ forkCreationLoading = false }} /> + {/if} {#if !automateUsernameCreation}