fix: default schedule handlers, unique trigger draft paths, zoned cadence, valid script names

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 13:18:46 +02:00
co-authored by Claude Opus 5.5
parent 77c5df6289
commit 146f770626
30 changed files with 282 additions and 160 deletions
@@ -21,7 +21,7 @@
import { layoutAssetGraph, PIPELINE_NODE_EXTRA_ROW } from './assetGraphLayout'
import { assetsOnlyView, type AssetUpstream } from './assetsOnlyView'
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
import { describeCron } from '$lib/utils/describeCron'
import { describeSchedule } from '$lib/utils/describeCron'
import Toggle from '$lib/components/Toggle.svelte'
import { computeMutedReadKeys, dbtAssociations } from './resolveGraph'
import { buildDownstreamMap } from './graphTraversal'
@@ -732,6 +732,7 @@
missing: boolean
draft: boolean
schedule?: string
timezone?: string
summary?: string
// First target script (drives the per-script create/edit flows).
runnable_path?: string
@@ -751,6 +752,7 @@
draft: boolean,
runnable_path?: string,
schedule?: string,
timezone?: string,
summary?: string
) {
const prev = triggerSourceNodes.get(id)
@@ -762,6 +764,7 @@
missing,
draft,
schedule,
timezone,
summary,
runnable_path,
runnable_paths: runnable_path ? [runnable_path] : []
@@ -820,6 +823,7 @@
// on every attached native trigger.
t.runnable_path,
t.schedule,
t.timezone,
t.summary
)
edges.push({
@@ -850,6 +854,7 @@
missing: info.missing,
draft: info.draft,
schedule: info.schedule,
timezone: info.timezone,
summary: info.summary,
runnable_path: info.runnable_path,
// data_upload nodes go green once a file is staged for their
@@ -910,7 +915,7 @@
const style = TRIGGER_NODE_STYLE[t.kind as TriggerNodeKind]
const label =
t.kind === 'schedule' && t.data?.schedule
? (describeCron(t.data.schedule) ?? `Schedule (${t.data.schedule})`)
? (describeSchedule(t.data.schedule, t.data.timezone) ?? `Schedule (${t.data.schedule})`)
: (style?.label ?? t.kind)
return {
label,
@@ -1450,7 +1455,7 @@
{#if assetsOnlyToggle}
<Panel
position="top-left"
class="!m-3 px-2 py-1 rounded-md bg-surface border border-gray-200 dark:border-gray-700 shadow-sm"
class="!m-3"
>
<Toggle bind:checked={assetsOnly} size="xs" options={{ right: 'Assets only' }} />
</Panel>
@@ -67,6 +67,7 @@
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import ScheduleAdvancedOptions, {
emptyScheduleAdvanced,
loadDefaultScheduleAdvanced,
scheduleAdvancedCfg
} from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte'
import { workspaceStore } from '$lib/stores'
@@ -147,14 +148,31 @@
tableEdited: false
})
let config = $state(buildEmptyConfig())
loadAdvancedDefaults()
// The workspace's default handlers, as the schedule drawer applies to a new schedule.
function loadAdvancedDefaults() {
const ws = $workspaceStore
if (!ws) return
const target = config
loadDefaultScheduleAdvanced(ws)
.then((d) => {
if (config === target) config.advanced = d
})
.catch(() => {})
}
function resetWizard() {
selected = buildEmptySelected()
config = buildEmptyConfig()
loadAdvancedDefaults()
step = 0
}
const TABLE_NAME_RE = /^[^/\s]+$/
// The path grammar of `Path.svelte`: word segments, no empty or trailing ones.
const SCRIPT_NAME_RE = /^[\w-]+(\/[\w-]+)*$/
let scriptNameValid = $derived(SCRIPT_NAME_RE.test(selected.scriptPath.trim()))
let assetValid = $derived(
!!config.asset?.store && TABLE_NAME_RE.test(config.asset.table.trim())
)
@@ -163,7 +181,7 @@
? config.validCron && !!config.schedule.trim() && !config.schedulePathError
: currentConfigStep === 'asset'
? assetValid
: !!selected.scriptPath.trim()
: scriptNameValid && !!selected.triggerId && !!selected.language && !!selected.outputId
)
function enterAssetStep() {
@@ -428,14 +446,30 @@
{#snippet bottomSection(close: () => void)}
<Label label="Path">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={pathEl} class="flex" onkeydown={selectAndAdvanceTo(() => aiPromptEl ?? saveEl)}>
<div
bind:this={pathEl}
class="flex"
onkeydown={(e) =>
(e.metaKey || e.ctrlKey) && e.key === 'Enter'
? (e.preventDefault(), e.stopPropagation(), confirm(close))
: selectAndAdvanceTo(() => aiPromptEl ?? saveEl)(e)}
>
<div
class="border rounded-md rounded-r-none border-r-0 text-xs w-fit shrink-0 whitespace-nowrap flex items-center px-2 text-secondary bg-surface-input"
>
{pathPrefix}
</div>
<TextInput bind:value={selected.scriptPath} class="rounded-l-none" />
<TextInput
bind:value={selected.scriptPath}
class="rounded-l-none"
error={!scriptNameValid && !!selected.scriptPath.trim()}
/>
</div>
{#if !scriptNameValid && selected.scriptPath.trim()}
<span class="text-2xs text-red-600 dark:text-red-400">
Letters, digits, <code>_</code> and <code>-</code>, in <code>/</code>-separated segments
</span>
{/if}
</Label>
{#if !$copilotInfo.workspaceDisabled}
<Label label="AI Prompt (optional)">
@@ -1,5 +1,6 @@
<script lang="ts">
import { sendUserToast } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { NativeTriggerKind, PipelineTriggerDraftKind } from './types'
import {
@@ -23,6 +24,10 @@
import GcpTriggerEditor from '$lib/components/triggers/gcp/GcpTriggerEditor.svelte'
import EmailTriggerEditor from '$lib/components/triggers/email/EmailTriggerEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import {
loadDefaultScheduleAdvanced,
scheduleAdvancedCfg
} from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte'
import WebhookEditor from '$lib/components/triggers/webhook/WebhookEditor.svelte'
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -111,21 +116,37 @@
}
}
/** The kind's full editor over a pipeline-local draft; Save hands the config back.
/** The kind's full editor over a pipeline-local draft; Save hands the config back,
* and the drawer stays open when `onSave` refuses it.
* `saved` is the draft being edited (absent for a new one), applied over `defaults`. */
export function openTriggerDraft(
kind: PipelineTriggerDraftKind,
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSave: (cfg: Record<string, any>) => void
onSave: (cfg: Record<string, any>) => boolean
) {
switch (kind) {
case 'schedule':
return scheduleEditor?.openDraft(
{ is_flow: false, args: {}, ...defaults, ...saved, script_path: scriptPath } as Schedule,
onSave
)
case 'schedule': {
// A new draft starts from the workspace's default handlers, like a new
// schedule does; the drawer only applies them when it opens without one.
const open = (advanced: Record<string, any>) =>
scheduleEditor?.openDraft(
{
is_flow: false,
args: {},
...advanced,
...defaults,
...saved,
script_path: scriptPath
} as Schedule,
onSave
)
if (saved || !$workspaceStore) return open({})
return loadDefaultScheduleAdvanced($workspaceStore)
.then((a) => open(scheduleAdvancedCfg(a)))
.catch(() => open({}))
}
case 'kafka':
return kafkaEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'mqtt':
@@ -50,7 +50,7 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import { describeCron } from '$lib/utils/describeCron'
import { describeSchedule } from '$lib/utils/describeCron'
import { twMerge } from 'tailwind-merge'
import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -75,6 +75,7 @@
draft?: boolean
// The cron, for a schedule: the node names its cadence instead of "Schedule".
schedule?: string
timezone?: string
// Shown as the title over the trigger's path, when the trigger has one.
summary?: string
runnable_path?: string
@@ -210,7 +211,7 @@
// when it has no plain reading); every other kind names its kind.
let kindName = $derived(
data.kind === 'schedule' && data.schedule
? (describeCron(data.schedule) ?? `Schedule (${data.schedule})`)
? (describeSchedule(data.schedule, data.timezone) ?? `Schedule (${data.schedule})`)
: style.label
)
@@ -142,13 +142,17 @@ export class PipelineEditorState {
})
}
/** Add or replace a trigger draft; `previousKey` drops the entry it was saved
* under when an edit renamed it. */
setTriggerDraft = (d: PipelineTriggerDraft, previousKey?: string) => {
/** Add or replace a trigger draft; `previousKey` is the entry being edited, which
* a rename drops. False, and nothing changes, when the path is another draft's:
* both would deploy to one trigger. */
setTriggerDraft = (d: PipelineTriggerDraft, previousKey?: string): boolean => {
const key = triggerDraftKey(d.kind, d.config.path)
if (key !== previousKey && this.triggerDrafts.has(key)) return false
const next = new Map(this.triggerDrafts)
if (previousKey) next.delete(previousKey)
next.set(triggerDraftKey(d.kind, d.config.path), d)
next.set(key, d)
this.triggerDrafts = next
return true
}
discardTriggerDraft = (key: string) => {
@@ -124,4 +124,17 @@ describe('PipelineEditorState trigger drafts', () => {
pe.discardDraft('f/x/m')
expect(pe.triggerDrafts.size).toBe(0)
})
it('refuse a path another draft holds, but let a draft keep its own', () => {
const pe = new PipelineEditorState()
const a = { kind: 'kafka' as const, config: { path: 'f/x/k', script_path: 'f/x/a' } }
expect(pe.setTriggerDraft(a)).toBe(true)
expect(
pe.setTriggerDraft({ kind: 'kafka', config: { path: 'f/x/k', script_path: 'f/x/b' } })
).toBe(false)
expect(pe.triggerDrafts.get('kafka:f/x/k')?.config.script_path).toBe('f/x/a')
expect(pe.setTriggerDraft({ ...a, config: { ...a.config, topics: ['t'] } }, 'kafka:f/x/k')).toBe(
true
)
})
})
@@ -309,7 +309,9 @@ function withTriggerDrafts(
runnable_path: d.config.script_path,
unsaved: true,
draft: true,
...(d.kind === 'schedule' ? { schedule: d.config.schedule as string } : {}),
...(d.kind === 'schedule'
? { schedule: d.config.schedule as string, timezone: d.config.timezone as string }
: {}),
...(d.config.summary ? { summary: d.config.summary as string } : {})
}))
]
@@ -214,8 +214,9 @@ export type AssetGraphTrigger =
// A pipeline-local `PipelineTriggerDraft`: `path` is the trigger it
// will be deployed as, and no row exists yet.
draft?: boolean
// A schedule's cron and summary, filled in client-side, for the node's labels.
// A schedule's cron, zone and summary, filled in client-side, for the node's labels.
schedule?: string
timezone?: string
summary?: string
}
@@ -26,7 +26,7 @@
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await tickPainted()
@@ -174,7 +174,7 @@
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
@@ -219,7 +219,7 @@
fixedScriptPath_?: string,
defaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
onSaveDraft?: (cfg: Record<string, any>) => boolean
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
@@ -348,7 +348,7 @@
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
drawer?.closeDrawer()
return
}
@@ -32,7 +32,7 @@
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await tickPainted()
@@ -141,7 +141,7 @@
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
@@ -195,7 +195,7 @@
fixedScriptPath_?: string,
defaultValues?: Partial<EmailTrigger>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
onSaveDraft?: (cfg: Record<string, any>) => boolean
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
@@ -289,7 +289,7 @@
async function triggerScript(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getEmailTriggerConfig()))
if (!saveDraftHandler($state.snapshot(getEmailTriggerConfig()))) return
drawer?.closeDrawer()
return
}
@@ -27,7 +27,7 @@
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await tickPainted()
@@ -165,7 +165,7 @@
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
@@ -208,7 +208,7 @@
fixedScriptPath_?: string,
defaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
onSaveDraft?: (cfg: Record<string, any>) => boolean
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
@@ -320,7 +320,7 @@
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getGcpConfig()))
if (!saveDraftHandler($state.snapshot(getGcpConfig()))) return
drawer?.closeDrawer()
return
}
@@ -26,7 +26,7 @@
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await tickPainted()
@@ -176,7 +176,7 @@
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
@@ -224,7 +224,7 @@
fixedScriptPath_?: string,
nDefaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
onSaveDraft?: (cfg: Record<string, any>) => boolean
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
@@ -347,7 +347,7 @@
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
drawer?.closeDrawer()
return
}
@@ -26,7 +26,7 @@
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await tickPainted()
@@ -171,7 +171,7 @@
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
@@ -219,7 +219,7 @@
fixedScriptPath_?: string,
defaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
onSaveDraft?: (cfg: Record<string, any>) => boolean
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
@@ -360,7 +360,7 @@
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
drawer?.closeDrawer()
return
}
@@ -26,7 +26,7 @@
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await tickPainted()
@@ -159,7 +159,7 @@
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
@@ -213,7 +213,7 @@
fixedScriptPath_?: string,
nDefaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
onSaveDraft?: (cfg: Record<string, any>) => boolean
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
@@ -339,7 +339,7 @@
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
drawer?.closeDrawer()
return
}
@@ -26,7 +26,7 @@
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await tickPainted()
@@ -260,7 +260,7 @@
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
@@ -314,7 +314,7 @@
defaultValues?: Record<string, any>,
newDraft?: boolean,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
onSaveDraft?: (cfg: Record<string, any>) => boolean
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
@@ -490,7 +490,7 @@
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
drawer?.closeDrawer()
return
}
@@ -1,6 +1,6 @@
<script lang="ts" module>
import { handlerFullPath } from '$lib/components/ErrorOrRecoveryHandler.svelte'
import type { ErrorHandler, Retry } from '$lib/gen'
import { SettingService, type ErrorHandler, type Retry } from '$lib/gen'
export type ScheduleAdvancedState = {
errorHandlerSelected: ErrorHandler
@@ -48,6 +48,79 @@
}
}
/** Which built-in handler a hub path is, or `custom`. */
export function getHandlerType(
isHandler: 'error' | 'recovery' | 'success',
scriptPath: string
): ErrorHandler {
const handlerMap = {
error: {
teams: '/workspace-or-schedule-error-handler-teams',
slack: '/workspace-or-schedule-error-handler-slack',
email: '/workspace-or-error-handler-email'
},
recovery: {
teams: '/schedule-recovery-handler-teams',
slack: '/schedule-recovery-handler-slack',
email: '/workspace-or-error-handler-email'
},
success: {
teams: '/schedule-success-handler-teams',
slack: '/schedule-success-handler-slack',
email: '/workspace-or-error-handler-email'
}
}
for (const [type, suffix] of Object.entries(handlerMap[isHandler])) {
if (scriptPath.startsWith('hub/') && scriptPath.endsWith(suffix)) {
return type as ErrorHandler
}
}
return 'custom'
}
const splitHandler = (path: string) => {
const [kind, ...rest] = path.split('/')
return { kind: kind as 'flow' | 'script', path: rest.join('/') }
}
/** The Advanced tabs of a new schedule: the workspace's default error, recovery
* and success handlers, where it sets them. */
export async function loadDefaultScheduleAdvanced(wsId: string): Promise<ScheduleAdvancedState> {
const a = emptyScheduleAdvanced()
const [err, rec, suc] = (await Promise.all(
['error', 'recovery', 'success'].map((k) =>
SettingService.getGlobal({ key: `default_${k}_handler_${wsId}` })
)
)) as any[]
if (err) {
const h = splitHandler(err.errorHandlerPath)
a.wsErrorHandlerMuted = err.wsErrorHandlerMuted
a.errorHandleritemKind = h.kind
a.errorHandlerPath = h.path
a.errorHandlerExtraArgs = err.errorHandlerExtraArgs
a.errorHandlerSelected = getHandlerType('error', h.path)
a.failedTimes = err.failedTimes
a.failedExact = err.failedExact
}
if (rec) {
const h = splitHandler(rec.recoveryHandlerPath)
a.recoveryHandlerItemKind = h.kind
a.recoveryHandlerPath = h.path
a.recoveryHandlerExtraArgs = rec.recoveryHandlerExtraArgs
a.recoveryHandlerSelected = getHandlerType('recovery', h.path)
a.recoveredTimes = rec.recoveredTimes
}
if (suc) {
const h = splitHandler(suc.successHandlerPath)
a.successHandlerItemKind = h.kind
a.successHandlerPath = h.path
a.successHandlerExtraArgs = suc.successHandlerExtraArgs
a.successHandlerSelected = getHandlerType('success', h.path)
}
return a
}
/** The schedule fields the Advanced tabs own, as the schedule API takes them. */
export function scheduleAdvancedCfg(a: ScheduleAdvancedState) {
return {
@@ -33,7 +33,7 @@
/** Edit a schedule that exists only as the caller's draft; Save hands it back. */
export async function openDraft(
cfg: Schedule,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await loadInner()
@@ -6,7 +6,12 @@
} from '$lib/components/sessions/pageDrawerSession'
import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import ScheduleAdvancedOptions, { scheduleAdvancedCfg } from './ScheduleAdvancedOptions.svelte'
import ScheduleAdvancedOptions, {
emptyScheduleAdvanced,
getHandlerType,
loadDefaultScheduleAdvanced,
scheduleAdvancedCfg
} from './ScheduleAdvancedOptions.svelte'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import CronInput from '$lib/components/CronInput.svelte'
@@ -24,7 +29,6 @@
type Script,
ScriptService,
type Flow,
SettingService,
type Retry,
type Schedule,
type ErrorHandler
@@ -129,7 +133,7 @@
// Set by `openNew({ onSaveDraft })`: the caller keeps the schedule as its own
// draft, so Save hands it the config instead of writing it, and nothing may
// autosave a workspace trigger draft for a path that is not deployed.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
let permissionedAs = $state<string | undefined>(undefined)
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
@@ -248,66 +252,23 @@
successHandlerExtraArgs = {}
}
} else {
let defaultErrorHandlerMaybe = undefined
let defaultRecoveryHandlerMaybe = undefined
let defaultSuccessHandlerMaybe = undefined
if (wsId) {
defaultErrorHandlerMaybe = (await SettingService.getGlobal({
key: 'default_error_handler_' + wsId!
})) as any
defaultRecoveryHandlerMaybe = (await SettingService.getGlobal({
key: 'default_recovery_handler_' + wsId!
})) as any
defaultSuccessHandlerMaybe = (await SettingService.getGlobal({
key: 'default_success_handler_' + wsId!
})) as any
}
if (defaultErrorHandlerMaybe !== undefined && defaultErrorHandlerMaybe !== null) {
wsErrorHandlerMuted = defaultErrorHandlerMaybe['wsErrorHandlerMuted']
let splitted = (defaultErrorHandlerMaybe['errorHandlerPath'] as string).split('/')
errorHandleritemKind = splitted[0] as 'flow' | 'script'
errorHandlerPath = splitted.slice(1)?.join('/')
errorHandlerExtraArgs = defaultErrorHandlerMaybe['errorHandlerExtraArgs']
errorHandlerSelected = getHandlerType('error', errorHandlerPath)
failedTimes = defaultErrorHandlerMaybe['failedTimes']
failedExact = defaultErrorHandlerMaybe['failedExact']
} else {
wsErrorHandlerMuted = false
errorHandlerPath = undefined
errorHandleritemKind = 'script'
errorHandlerExtraArgs = {}
errorHandlerSelected = 'slack'
failedTimes = 1
failedExact = false
}
if (defaultRecoveryHandlerMaybe !== undefined && defaultRecoveryHandlerMaybe !== null) {
let splitted = (defaultRecoveryHandlerMaybe['recoveryHandlerPath'] as string).split('/')
recoveryHandlerItemKind = splitted[0] as 'flow' | 'script'
recoveryHandlerPath = splitted.slice(1)?.join('/')
recoveryHandlerExtraArgs = defaultRecoveryHandlerMaybe['recoveryHandlerExtraArgs']
recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath)
recoveredTimes = defaultRecoveryHandlerMaybe['recoveredTimes']
} else {
recoveryHandlerPath = undefined
recoveryHandlerItemKind = 'script'
recoveryHandlerExtraArgs = {}
recoveryHandlerSelected = 'slack'
recoveredTimes = 1
}
if (defaultSuccessHandlerMaybe !== undefined && defaultSuccessHandlerMaybe !== null) {
let splitted = (defaultSuccessHandlerMaybe['successHandlerPath'] as string).split('/')
successHandlerItemKind = splitted[0] as 'flow' | 'script'
successHandlerPath = splitted.slice(1)?.join('/')
successHandlerExtraArgs = defaultSuccessHandlerMaybe['successHandlerExtraArgs']
successHandlerSelected = getHandlerType('success', successHandlerPath)
recoveredTimes = defaultSuccessHandlerMaybe['recoveredTimes']
} else {
successHandlerPath = undefined
successHandlerItemKind = 'script'
successHandlerExtraArgs = {}
successHandlerSelected = 'slack'
}
const d = wsId ? await loadDefaultScheduleAdvanced(wsId) : emptyScheduleAdvanced()
wsErrorHandlerMuted = d.wsErrorHandlerMuted
errorHandlerPath = d.errorHandlerPath
errorHandleritemKind = d.errorHandleritemKind
errorHandlerExtraArgs = d.errorHandlerExtraArgs
errorHandlerSelected = d.errorHandlerSelected
failedTimes = d.failedTimes
failedExact = d.failedExact
recoveryHandlerPath = d.recoveryHandlerPath
recoveryHandlerItemKind = d.recoveryHandlerItemKind
recoveryHandlerExtraArgs = d.recoveryHandlerExtraArgs
recoveryHandlerSelected = d.recoveryHandlerSelected
recoveredTimes = d.recoveredTimes
successHandlerPath = d.successHandlerPath
successHandlerItemKind = d.successHandlerItemKind
successHandlerExtraArgs = d.successHandlerExtraArgs
successHandlerSelected = d.successHandlerSelected
}
}
@@ -317,7 +278,7 @@
defaultValues?: Schedule,
schedule_path?: string,
fixedScriptPath_?: string,
opts: { getDraft?: boolean; onSaveDraft?: (cfg: Record<string, any>) => void } = {}
opts: { getDraft?: boolean; onSaveDraft?: (cfg: Record<string, any>) => boolean } = {}
) {
const getDraft = opts.getDraft ?? true
saveDraftHandler = opts.onSaveDraft
@@ -536,7 +497,7 @@
const previousPath = initialPath
const scheduleCfg = getScheduleCfg()
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(scheduleCfg))
if (!saveDraftHandler($state.snapshot(scheduleCfg))) return
drawer?.closeDrawer()
return
}
@@ -550,35 +511,6 @@
deploymentLoading = false
}
function getHandlerType(
isHandler: 'error' | 'recovery' | 'success',
scriptPath: string
): ErrorHandler {
const handlerMap = {
error: {
teams: '/workspace-or-schedule-error-handler-teams',
slack: '/workspace-or-schedule-error-handler-slack',
email: '/workspace-or-error-handler-email'
},
recovery: {
teams: '/schedule-recovery-handler-teams',
slack: '/schedule-recovery-handler-slack',
email: '/workspace-or-error-handler-email'
},
success: {
teams: '/schedule-success-handler-teams',
slack: '/schedule-success-handler-slack',
email: '/workspace-or-error-handler-email'
}
}
for (const [type, suffix] of Object.entries(handlerMap[isHandler])) {
if (scriptPath.startsWith('hub/') && scriptPath.endsWith(suffix)) {
return type as ErrorHandler
}
}
return 'custom'
}
let drawer: Drawer | undefined = $state()
@@ -26,7 +26,7 @@
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
onSaveDraft: (cfg: Record<string, any>) => boolean
) {
open = true
await tickPainted()
@@ -154,7 +154,7 @@
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let saveDraftHandler: ((cfg: Record<string, any>) => boolean) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
@@ -206,7 +206,7 @@
fixedScriptPath_?: string,
defaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
onSaveDraft?: (cfg: Record<string, any>) => boolean
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
@@ -349,7 +349,7 @@
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
if (!saveDraftHandler($state.snapshot(getSaveCfg()))) return
drawer?.closeDrawer()
return
}
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { describeCron } from './describeCron'
import { describeCron, describeSchedule } from './describeCron'
describe('describeCron', () => {
it.each([
@@ -28,3 +28,10 @@ describe('describeCron', () => {
}
)
})
describe('describeSchedule', () => {
it('names the zone of a wall-clock time only', () => {
expect(describeSchedule('0 0 12 * * *', 'UTC')).toBe('Every day at 12:00 UTC')
expect(describeSchedule('0 0 * * * *', 'UTC')).toBe('Every hour')
})
})
+20
View File
@@ -106,3 +106,23 @@ export function describeCron(cron: string): string | undefined {
}
return undefined
}
function shortZone(timezone: string): string {
try {
return (
new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short' })
.formatToParts(new Date())
.find((p) => p.type === 'timeZoneName')?.value ?? timezone
)
} catch {
return timezone
}
}
/** `describeCron` of a schedule, naming its zone whenever the text states a
* wall-clock time: "Every day at 12:00 UTC". */
export function describeSchedule(cron: string, timezone?: string): string | undefined {
const text = describeCron(cron)
if (!text || !timezone || !/ at \d/.test(text)) return text
return `${text} ${shortZone(timezone)}`
}
@@ -452,14 +452,17 @@
pe.activeDraftPath = scriptPath
pe.selection = undefined
if (options?.schedule) {
pe.setTriggerDraft({
const path = options.schedule.path || defaultTriggerPath(scriptPath, 'schedule')
const saved = pe.setTriggerDraft({
kind: 'schedule',
config: {
...options.schedule,
path: options.schedule.path || defaultTriggerPath(scriptPath, 'schedule'),
script_path: scriptPath
}
config: { ...options.schedule, path, script_path: scriptPath }
})
if (!saved) {
sendUserToast(
`Another draft schedule already uses the path ${path}; the new script has no schedule`,
true
)
}
}
// Follow the new node with a smooth pan. The id matches the runnable
@@ -2372,11 +2375,13 @@
const defaults = { path: defaultTriggerPath(scriptPath, kind) }
triggerEditors?.openTriggerDraft(kind, scriptPath, defaults, draft?.config, (cfg) => {
const { extra_perms: _, ...config } = cfg
pe.setTriggerDraft(
const saved = pe.setTriggerDraft(
{ kind, config: { ...config, path: cfg.path, script_path: scriptPath, is_flow: false } },
// The editor's Path field can rename it; drafts are keyed by path.
draft ? triggerDraftKey(kind, draft.config.path) : undefined
)
if (!saved) sendUserToast(`Another draft trigger already uses the path ${cfg.path}`, true)
return saved
})
}
@@ -2467,12 +2472,12 @@
}
)
// Cron and summary of the deployed schedules on the graph, for their nodes'
// Cron, zone and summary of the deployed schedules on the graph, for their nodes'
// labels. Re-read with every graph load, so an edit in the schedule drawer shows.
let scheduleInfo = resource(
[() => $workspaceStore, () => graphRes.current],
async ([ws, g]) => {
const info = new Map<string, { schedule: string; summary?: string }>()
const info = new Map<string, { schedule: string; timezone: string; summary?: string }>()
if (!ws || !g) return info
const paths = new Set<string>()
for (const t of g.triggers) {
@@ -2482,7 +2487,11 @@
[...paths].map(async (path) => {
try {
const s = await ScheduleService.getSchedule({ workspace: ws, path })
info.set(path, { schedule: s.schedule, summary: s.summary || undefined })
info.set(path, {
schedule: s.schedule,
timezone: s.timezone,
summary: s.summary || undefined
})
} catch {
// Unreadable schedule: its node keeps the plain "Schedule" label.
}