diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 3a273cdd17..fac4b9d2e0 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -865,11 +865,11 @@ async fn list_schedule( // The `draft` row's key addresses the schedule; the value's `path` is // only where it will deploy. Reporting the key as `path` is what lets // a renamed draft still be opened, updated and deleted. + // The row key addresses the draft, so a value with no `path` yet (a + // half-filled editor) is still listable — same as the trigger handler. let intended_path = v.get("path").and_then(|s| s.as_str()).unwrap_or(""); - if intended_path.is_empty() { - continue; - } - let draft_path = (intended_path != row.path).then(|| intended_path.to_string()); + let draft_path = + (!intended_path.is_empty() && intended_path != row.path).then(|| intended_path.to_string()); let path = row.path.clone(); let schedule = v .get("schedule") diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 6a9b17939e..634c6bb45f 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -672,7 +672,9 @@ async fn list_triggers( // only where it will deploy. Surfacing the key as `path` is what lets // a renamed draft still be opened, updated and deleted. if let Some(intended) = map.insert("path".into(), row.path.clone().into()) { - if intended.as_str() != Some(row.path.as_str()) { + let intended_str = intended.as_str().unwrap_or(""); + // A half-filled editor may not have a path yet; that is not a rename. + if !intended_str.is_empty() && intended_str != row.path { map.insert("draft_path".into(), intended); } } diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index e83d6495b5..f8f8349345 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -1188,7 +1188,9 @@ selectedId && untrack(() => select(selectedId)) }) $effect.pre(() => { - initialPath && initialPath != '' && opWorkspace && untrack(() => loadTriggers()) + // Not gated on `initialPath`: an undeployed flow has no deployed triggers + // but can already have drafted ones (loadTriggers falls back to $pathStore). + opWorkspace && untrack(() => loadTriggers()) }) $effect.pre(() => { const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false diff --git a/frontend/src/lib/components/SchedulePanel.svelte b/frontend/src/lib/components/SchedulePanel.svelte index 65412583fd..3c1c3b5bcb 100644 --- a/frontend/src/lib/components/SchedulePanel.svelte +++ b/frontend/src/lib/components/SchedulePanel.svelte @@ -13,9 +13,14 @@ ...restProps } = $props() - function openScheduleEditor(isFlow: boolean) { + async function openScheduleEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - scheduleEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await scheduleEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { scheduleEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues) } diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index c35dcaefa1..e8619543fa 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -309,18 +309,19 @@ }) export async function loadTriggers() { - if (!initialPath) { - return + // An undeployed script has no deployed triggers to count, but it can already + // have drafted ones — fetch against the path they were drafted at. + if (initialPath) { + $triggersCount = await ScriptService.getTriggersCountOfScript({ + workspace: opWorkspace!, + path: initialPath + }) } - $triggersCount = await ScriptService.getTriggersCountOfScript({ - workspace: opWorkspace!, - path: initialPath - }) await triggersState.fetchTriggers( triggersCount, opWorkspace, - initialPath, + initialPath || script.path, false, $primaryScheduleStore, $userStore @@ -1017,7 +1018,9 @@ } } $effect(() => { - initialPath != '' && untrack(() => loadTriggers()) + // Not gated on `initialPath`: an undeployed script can already have drafted + // triggers (loadTriggers falls back to the live path). + untrack(() => loadTriggers()) }) let langs = $derived( processLangs(script.language, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages)) diff --git a/frontend/src/lib/components/triggers/TriggersEditor.svelte b/frontend/src/lib/components/triggers/TriggersEditor.svelte index ae428e8e08..e8978f0106 100644 --- a/frontend/src/lib/components/triggers/TriggersEditor.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditor.svelte @@ -2,7 +2,7 @@ import { userStore, workspaceStore } from '$lib/stores' import { UserDraft } from '$lib/userDraft.svelte' import FlowCard from '../flows/common/FlowCard.svelte' - import { getContext, onDestroy, createEventDispatcher } from 'svelte' + import { getContext, onDestroy, createEventDispatcher, tick } from 'svelte' import type { TriggerContext } from '$lib/components/triggers' import { Pane, Splitpanes } from 'svelte-splitpanes' import TriggersTable from './TriggersTable.svelte' @@ -171,9 +171,10 @@ triggersState.deleteTrigger(triggersCount, triggerIndex) triggersState.selectedTriggerIndex = triggersState.triggers.length - 1 + // Let the panel unmount before dropping the row: its autosave still holds + // the config and would write it straight back as a fresh draft. + await tick() // Drop the draft row too, else the trigger reappears on the next fetch. - // After the list update, so the editor panel's autosave has unmounted - // rather than re-persisting the config it still holds. const draftKind = triggerDraftKind(trigger.type) if (draftKind && trigger.path && $workspaceStore) { UserDraft.remove(draftKind, trigger.path, { workspace: $workspaceStore }) diff --git a/frontend/src/lib/components/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/triggers/TriggersWrapper.svelte index 0e363c85c1..df5e5f28df 100644 --- a/frontend/src/lib/components/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/triggers/TriggersWrapper.svelte @@ -55,6 +55,15 @@ onEmailDomain, ...props }: Props = $props() + // `newTriggerSeed` seeds `openNew` and applies ONLY while the trigger is new. + // Once its row exists, passing anything here would short-circuit the editors' + // backend load (`loadTrigger` returns early on a `defaultConfig`) and show the + // seed instead of the saved draft — overwriting it on the next autosave. + const draftDefaults = $derived( + selectedTrigger.isNew + ? selectedTrigger.newTriggerSeed + : (selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined) + ) {#if selectedTrigger.type === 'http'} @@ -62,7 +71,7 @@ {selectedTrigger} {isFlow} path={initialPath || fakeInitialPath} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -90,7 +99,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {schema} {customLabel} {...props} @@ -100,7 +109,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -109,7 +118,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -118,7 +127,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -127,7 +136,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -136,7 +145,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -145,7 +154,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -154,7 +163,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -163,7 +172,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -172,7 +181,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -181,7 +190,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {onEmailDomain} {...props} @@ -194,7 +203,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -204,7 +213,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> @@ -214,7 +223,7 @@ {isFlow} path={initialPath || fakeInitialPath} {selectedTrigger} - defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined} + defaultValues={draftDefaults} {customLabel} {...props} /> diff --git a/frontend/src/lib/components/triggers/amqp/AmqpTriggersPanel.svelte b/frontend/src/lib/components/triggers/amqp/AmqpTriggersPanel.svelte index ed9f2ab3b0..68ae5ebb93 100644 --- a/frontend/src/lib/components/triggers/amqp/AmqpTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/amqp/AmqpTriggersPanel.svelte @@ -19,7 +19,12 @@ async function openAmqpTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - amqpTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await amqpTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { amqpTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig) } diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerPanel.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerPanel.svelte index bd7942ba32..3d8b2e06fd 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerPanel.svelte @@ -19,7 +19,12 @@ async function openAzureTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - azureTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await azureTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { azureTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues) } diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte index 59a1487cf0..a82db74617 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte @@ -31,7 +31,12 @@ async function openEmailTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - emailTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await emailTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { emailTriggerEditor?.openEdit(selectedTrigger.path ?? '', isFlow, defaultValues) } diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerPanel.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerPanel.svelte index 61abe3584d..8ba4134eca 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerPanel.svelte @@ -19,7 +19,12 @@ async function openGcpTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - gcpTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await gcpTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { gcpTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues) } diff --git a/frontend/src/lib/components/triggers/http/RoutesPanel.svelte b/frontend/src/lib/components/triggers/http/RoutesPanel.svelte index d9cabd9f6c..7eaed7ebf8 100644 --- a/frontend/src/lib/components/triggers/http/RoutesPanel.svelte +++ b/frontend/src/lib/components/triggers/http/RoutesPanel.svelte @@ -18,7 +18,12 @@ async function openRouteEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - routeEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await routeEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { routeEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues) } diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte index 2eb75fbe74..f56fd7c777 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte @@ -20,7 +20,12 @@ async function openKafkaTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - kafkaTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await kafkaTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { kafkaTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues) } diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte index 7a529a736f..b4d0865c64 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte @@ -19,7 +19,12 @@ async function openMqttTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - mqttTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await mqttTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { mqttTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig) } diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte index 75c74e007a..df3e45edd4 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte @@ -19,7 +19,12 @@ async function openNatsTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - natsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await natsTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { natsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig) } diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte index 1f546cb9ac..434dc8bf93 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte @@ -21,7 +21,12 @@ async function openPostgresTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - postgresTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }, newDraft) + await postgresTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }, newDraft) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { postgresTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues) } diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte index f9156aa413..fcace81e99 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte @@ -20,7 +20,12 @@ async function openSqsTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - sqsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await sqsTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { sqsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig) } diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts index 1610ec8504..dce37aed85 100644 --- a/frontend/src/lib/components/triggers/triggers.svelte.ts +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -34,6 +34,7 @@ import { type Trigger, type TriggerDraftTarget } from './utils' +import { UserDraft } from '$lib/userDraft.svelte' import { get, type Writable } from 'svelte/store' import type { TriggerType } from './utils' import type { UserExt } from '$lib/stores' @@ -89,26 +90,29 @@ export class Triggers { return $state.snapshot(this.#selectedTriggerIndex) } - /** Add an undeployed trigger to the editor's list. For the draft-backed kinds - * this writes the `trigger_*` draft row up front — draft rows are path-keyed, - * so the trigger needs a path before the user has typed one, and persisting - * immediately is what makes it survive a reload. Native kinds have no draft - * row yet and keep their config in `draftConfig` until deploy. */ - addDraftTrigger( + /** Add an undeployed trigger to the editor's list. + * + * Draft rows are path-keyed, so the path is reserved here. Who writes the row + * depends on where the config comes from: a caller-supplied `seedCfg` is + * already complete (a scheduled poll, a trigger step's primary schedule), so + * it is written now and the trigger is deployable without ever opening its + * panel. Without one, only the editor's `openNew` can produce a complete + * config, so the trigger stays `isNew` until that autosave lands. + * + * Native kinds have no draft row at all and keep their config in + * `draftConfig` until they deploy. */ + async addDraftTrigger( triggersCountStore: Writable, type: TriggerType, target: TriggerDraftTarget, seedCfg?: Record - ): number { + ): Promise { const primaryScheduleExists = this.#triggers.some((t) => t.type === 'schedule' && t.isPrimary) const isPrimary = type === 'schedule' && !primaryScheduleExists const draftKind = triggerDraftKind(type) const { runnablePath, isFlow, workspace } = target - // Reserve the path the draft row will be keyed at. The row itself is - // written by the editor panel's autosave, which is what turns the form's - // defaults into a complete config — seeding a partial one here would leave - // required fields unset and crash the editors that don't default them. + // Reserve the path the draft row is keyed at. const path = draftKind && workspace ? newDraftTriggerPath( @@ -119,19 +123,30 @@ export class Triggers { ) : undefined + // The runnable is carried explicitly: the editors otherwise fall back to the + // fake capture path an undeployed runnable is given. + const seed = { ...seedCfg, script_path: runnablePath, is_flow: isFlow } + // Only a caller-supplied config is complete enough to store; writing the + // bare seed would leave required fields unset for the editors that read a + // draft back without defaulting them. + const writeNow = !!draftKind && !!workspace && !!path && !!seedCfg + + if (writeNow) { + UserDraft.save(draftKind!, path!, { ...seed, path }, { workspace }) + await UserDraft.forcePersist(draftKind!, path!, { workspace }) + } + const newTrigger: Trigger = { id: generateRandomString(), type, path, isPrimary, isDraft: true, - isNew: true, - hasDraft: !!draftKind, - // Until the row exists this is the editor's starting point, whatever the - // kind — `TriggersWrapper` hands it to `openNew` as defaults. It carries - // the runnable explicitly: the editors otherwise fall back to the fake - // capture path an undeployed runnable is given. - draftConfig: { ...seedCfg, script_path: runnablePath, is_flow: isFlow } + isNew: !!draftKind && !writeNow, + hasDraft: writeNow, + newTriggerSeed: seed, + // Pending state, for the kinds with nowhere else to hold it. + draftConfig: draftKind ? undefined : seed } this.#triggers.push(newTrigger) @@ -162,17 +177,21 @@ export class Triggers { ): number { const currentTriggers = this.#triggers // Preserve the editor-local config of the kinds that have no draft row - // (native); every other kind's pending state lives in the backend rows. + // (native); every other kind's pending state lives in the backend rows, and + // carrying anything local onto them would read as a permanent "Modified". const configMap = new Map>( currentTriggers - .filter((t) => t.type === type && t.draftConfig) + .filter((t) => t.type === type && t.draftConfig && !triggerDraftKind(t.type)) .map((t) => [t.path ?? '', t.draftConfig!]) ) const backendTriggers = remoteTriggers.map((trigger) => ({ type: type as TriggerType, path: trigger.path, - isPrimary: type === 'schedule' && trigger.path === trigger.script_path, + // `path` is the draft row's key on a draft-only row; the schedule is the + // primary when the path it will DEPLOY to is the runnable's own. + isPrimary: + type === 'schedule' && (trigger.draft_path ?? trigger.path) === trigger.script_path, // `draft_only` rows are synthesized from a draft with no deployed // counterpart; `is_draft` also covers a draft layered on a deployed row. isDraft: !!trigger.draft_only, diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index 2347a91998..0811a33d36 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -112,8 +112,11 @@ export type Trigger = { draftPath?: string /** Added in this editor session and not yet written to the `draft` table. Its * editor opens through `openNew`, the path that fills a complete config; the - * resulting autosave is what creates the row. */ + * resulting autosave is what creates the row, after which this clears. */ isNew?: boolean + /** Starting values for that `openNew`. NOT pending state — it is the defaults + * the form opens on, so it must not count as a change or reach a deploy. */ + newTriggerSeed?: Record /** Editor-local unsaved config, for the trigger kinds that have no draft row * yet (native only — see `triggerDraftKind`). */ draftConfig?: Record @@ -438,11 +441,21 @@ export async function repointTriggerDrafts( if (!kind || !trigger.path || !triggerHasPendingChanges(trigger)) return const draft = await readTriggerDraft(kind, trigger.path, workspace) if (!draft) return - if (draft.script_path === runnablePath && draft.is_flow === isFlow) return + // The primary schedule is the one deploying to the runnable's own path, + // so a rename moves where it deploys too — otherwise it stops reading as + // primary on the next reload and lands at the stale path. + const intendedPath = trigger.isPrimary ? runnablePath : draft.path + if ( + draft.script_path === runnablePath && + draft.is_flow === isFlow && + draft.path === intendedPath + ) { + return + } UserDraft.save( kind, trigger.path, - { ...draft, script_path: runnablePath, is_flow: isFlow }, + { ...draft, path: intendedPath, script_path: runnablePath, is_flow: isFlow }, { workspace } ) }) diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte index 408df1943f..7859151fce 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte @@ -18,7 +18,12 @@ async function openWebsocketTriggerEditor(isFlow: boolean) { if (selectedTrigger.isNew) { - wsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }) + await wsTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }) + // The autosave inside `openNew` created the draft row, so this trigger is + // no longer new: re-selecting it must reload that row, not reset the form + // to defaults and overwrite what the user just configured. + selectedTrigger.isNew = false + selectedTrigger.newTriggerSeed = undefined } else { wsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig) } diff --git a/frontend/src/lib/legacyDraftTriggers.ts b/frontend/src/lib/legacyDraftTriggers.ts index 4c28454faa..d8fd24b57a 100644 --- a/frontend/src/lib/legacyDraftTriggers.ts +++ b/frontend/src/lib/legacyDraftTriggers.ts @@ -16,7 +16,7 @@ * * Idempotent: the caller strips the field, so a converted draft has none. */ -import { UserDraft } from '$lib/userDraft.svelte' +import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte' import { newDraftTriggerPath, triggerDraftKind, type Trigger } from '$lib/components/triggers/utils' export async function migrateLegacyDraftTriggers(opts: { @@ -24,12 +24,12 @@ export async function migrateLegacyDraftTriggers(opts: { runnablePath: string isFlow: boolean workspace: string -}): Promise { +}): Promise<{ kind: UserDraftItemKind; path: string }[]> { const { legacy, runnablePath, isFlow, workspace } = opts - if (!Array.isArray(legacy) || legacy.length === 0) return false + if (!Array.isArray(legacy) || legacy.length === 0) return [] const taken: string[] = [] - let migrated = false + const migrated: { kind: UserDraftItemKind; path: string }[] = [] for (const trigger of legacy) { const cfg = trigger?.draftConfig const kind = trigger?.type ? triggerDraftKind(trigger.type) : undefined @@ -49,7 +49,7 @@ export async function migrateLegacyDraftTriggers(opts: { { workspace } ) await UserDraft.forcePersist(kind, path, { workspace }) - migrated = true + migrated.push({ kind, path }) } return migrated } diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 601a3e3300..b8d1c0aeae 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -43,6 +43,7 @@ import { classicAppDraftParts } from '$lib/appDiffSides' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' +import { migrateLegacyDraftTriggers } from '$lib/legacyDraftTriggers' import { userStore } from '$lib/stores' import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' import { saveHttpRouteFromCfg } from '$lib/components/triggers/http/utils' @@ -403,6 +404,24 @@ export async function fetchDraftBaseStale( } } + +/** Deploy the trigger drafts just promoted out of a legacy `draft_triggers` + * array. Before triggers had their own rows they deployed alongside their + * runnable, so deploying the runnable must still land them rather than leave + * them behind as drafts. Failures are surfaced but don't fail the deploy — the + * runnable is already live and the draft survives for a retry. */ +async function deployMigratedTriggers( + migrated: { kind: DraftKind; path: string }[], + workspace: string +): Promise { + for (const { kind, path } of migrated) { + const res = await deployDraft(kind, path, workspace, { draftOnly: true }) + if (!res.success) { + console.error(`Could not deploy migrated ${kind} at ${path}: ${res.error}`) + } + } +} + /** * Promote a draft to deployed by replaying the editor's create/update call with * the stored draft value. The matching draft row is deleted server-side by the @@ -428,10 +447,18 @@ export async function deployDraft( const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any const d = r.draft ?? r // Drop editor-only / server-managed keys; deploy as a real (non-draft) version. - const { draft_triggers: _t, draft_only: _o, ...rest } = d + const { draft_triggers: legacyTriggers, draft_only: _o, ...rest } = d const scriptPath = d.path ?? path // Deploy at the draft's path so a rename in the draft is honored (same as // the editor: createScript at the new path with parent_hash links lineage). + // Drafts written before triggers had their own rows carry them inline; + // promote them first — the create below deletes the draft they live in. + const migratedTriggers = await migrateLegacyDraftTriggers({ + legacy: legacyTriggers, + runnablePath: scriptPath, + isFlow: false, + workspace + }) await ScriptService.createScript({ workspace, requestBody: { @@ -441,6 +468,7 @@ export async function deployDraft( deployment_message: deploymentMessage } }) + await deployMigratedTriggers(migratedTriggers, workspace) } else if (kind === 'flow') { const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any const d = r.draft ?? r @@ -462,6 +490,12 @@ export async function deployDraft( labels: d.labels, deployment_message: deploymentMessage } + const migratedTriggers = await migrateLegacyDraftTriggers({ + legacy: d.draft_triggers, + runnablePath: requestBody.path, + isFlow: true, + workspace + }) // Draft-only flows have NO flow row (they live solely in the // draft table), so they deploy via createFlow; a draft on a // deployed flow updates it. @@ -470,6 +504,7 @@ export async function deployDraft( } else { await FlowService.updateFlow({ workspace, path, requestBody }) } + await deployMigratedTriggers(migratedTriggers, workspace) } else if (kind === 'app') { // `raw_app` is handled above; only visual apps reach here. const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index de1c947fa6..3dfd46957b 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -28,6 +28,7 @@ discardDraftAfterDeploy, runResetToDeployed } from '$lib/userDraftToast' + import { migrateLegacyDraftTriggers } from '$lib/legacyDraftTriggers' let version: undefined | number = $state(undefined) @@ -424,6 +425,20 @@ draftSync.draft = flowToRender } + // Drafts written before triggers had their own rows carry their trigger + // configs inline; promote them and drop the dead field. + const legacyTriggers = (flowToRender as any).draft_triggers + if (legacyTriggers && $workspaceStore) { + await migrateLegacyDraftTriggers({ + legacy: legacyTriggers, + runnablePath: (flowToRender as any).draft_path ?? flowToRender.path, + isFlow: true, + workspace: $workspaceStore + }) + const { draft_triggers: _legacy, ...rest } = draftSync.draft as any + draftSync.draft = rest + } + await initFlow(flow, flowStore, flowStateStore) if (tok !== loadFlowToken) return loading = false