feat: name a schedule node's cadence, like "Every day at 12:00"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 12:38:48 +02:00
co-authored by Claude Opus 5.5
parent 69cea7f3d9
commit d3e5100bf4
7 changed files with 196 additions and 6 deletions
@@ -717,6 +717,7 @@
ref: string
missing: boolean
draft: boolean
schedule?: string
// First target script (drives the per-script create/edit flows).
runnable_path?: string
// Every target script: a single (kind, ref) — e.g. one schedule —
@@ -733,7 +734,8 @@
unsaved: boolean,
missing: boolean,
draft: boolean,
runnable_path?: string
runnable_path?: string,
schedule?: string
) {
const prev = triggerSourceNodes.get(id)
if (!prev) {
@@ -743,6 +745,7 @@
ref,
missing,
draft,
schedule,
runnable_path,
runnable_paths: runnable_path ? [runnable_path] : []
})
@@ -798,7 +801,8 @@
// lock. Previously only set for missing, which silently
// disabled `canEdit` (and the resulting click affordance)
// on every attached native trigger.
t.runnable_path
t.runnable_path,
t.schedule
)
edges.push({
id: `trig-${t.trigger_kind}:${sourceId}->${runnableId}`,
@@ -827,6 +831,7 @@
unsaved: info.allUnsaved,
missing: info.missing,
draft: info.draft,
schedule: info.schedule,
runnable_path: info.runnable_path,
// data_upload nodes go green once a file is staged for their
// target script (see readyDataUploadPaths / page dataUploadArgs).
@@ -50,6 +50,7 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import PipelineNodeCard from './PipelineNodeCard.svelte'
import { describeCron } 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'
@@ -72,6 +73,8 @@
// Edit/delete go through the same callbacks, which the page routes
// to the draft instead of a trigger row.
draft?: boolean
// The cron, for a schedule: the node names its cadence instead of "Schedule".
schedule?: string
runnable_path?: string
// Page-supplied dispatcher that opens the matching native
// trigger drawer with `script_path` pre-filled. When absent
@@ -201,6 +204,14 @@
: [])
])
// A schedule names its cadence ("Every day at 4:00", or the cron itself
// 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})`)
: style.label
)
// One card for every state: which label, title and click the node gets.
let card = $derived.by(
(): {
@@ -223,7 +234,7 @@
}
if (canEdit)
return {
kindLabel: `${style.label}${data.draft ? ' · draft' : unsaved ? ' · unsaved' : ''}`,
kindLabel: `${kindName}${data.draft ? ' · draft' : unsaved ? ' · unsaved' : ''}`,
title: data.ref,
tooltip: data.draft
? `Draft ${style.label}: ${data.ref} — created when you save the pipeline. Click to edit.`
@@ -251,7 +262,7 @@
onclick: handleDataUploadClick
}
return {
kindLabel: `${style.label}${displayMissing ? ' · missing' : unsaved ? ' · unsaved' : ''}`,
kindLabel: `${displayMissing ? style.label : kindName}${displayMissing ? ' · missing' : unsaved ? ' · unsaved' : ''}`,
title: displayMissing ? 'no trigger row' : data.ref,
tooltip:
missingTitle ??
@@ -308,7 +308,8 @@ function withTriggerDrafts(
runnable_kind: 'script' as const,
runnable_path: d.config.script_path,
unsaved: true,
draft: true
draft: true,
...(d.kind === 'schedule' ? { schedule: d.config.schedule as string } : {})
}))
]
}
@@ -214,6 +214,8 @@ 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, filled in client-side, for the node's label.
schedule?: string
}
// Macro-library → consumer edge: the consumer calls `macro_names` of
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { describeCron } from './describeCron'
describe('describeCron', () => {
it.each([
['*/30 * * * * *', 'Every 30 seconds'],
['0 * * * * *', 'Every minute'],
['0 */15 * * * *', 'Every 15 minutes'],
['0 0 * * * *', 'Every hour'],
['0 30 * * * *', 'Every hour at :30'],
['0 0 */6 * * *', 'Every 6 hours'],
['0 0 4 * * *', 'Every day at 4:00'],
// five fields are padded at the end, like the schedule editor saves them
['0 0 12 * *', 'Every day at 12:00'],
['0 30 9 * * 1-5', 'Every weekday at 9:30'],
['0 0 9 * * MON', 'Every Monday at 9:00'],
['0 0 18 * * 1,3,5', 'Every Monday, Wednesday and Friday at 18:00'],
['0 0 8 1 * *', 'Every month on the 1st at 8:00'],
['0 0 8 22 * *', 'Every month on the 22nd at 8:00']
])('%s → %s', (cron, text) => {
expect(describeCron(cron)).toBe(text)
})
it.each(['0 0 8 1 1 *', '15 0 4 * * *', '0 0 4-6 * * *', 'not a cron'])(
'leaves %s to be shown as is',
(cron) => {
expect(describeCron(cron)).toBeUndefined()
}
)
})
+108
View File
@@ -0,0 +1,108 @@
import { formatCron } from '$lib/utils'
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const DAY_ALIASES: Record<string, number> = {
SUN: 0,
MON: 1,
TUE: 2,
WED: 3,
THU: 4,
FRI: 5,
SAT: 6
}
const isNum = (f: string) => /^\d+$/.test(f)
const everyN = (f: string) => /^\*\/(\d+)$/.exec(f)?.[1]
function time(hour: string, minute: string): string {
return `${Number(hour)}:${minute.padStart(2, '0')}`
}
function ordinal(n: number): string {
const rest = n % 100
if (rest >= 11 && rest <= 13) return `${n}th`
return `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}`
}
function dayNumber(token: string): number | undefined {
const t = token.toUpperCase()
if (t in DAY_ALIASES) return DAY_ALIASES[t]
if (!isNum(t)) return undefined
const n = Number(t)
return n >= 0 && n <= 7 ? n % 7 : undefined
}
/** `1-5`, `MON-FRI`, `1,3,5`, `6` → sorted day numbers; undefined for anything else. */
function days(field: string): number[] | undefined {
const out = new Set<number>()
for (const part of field.split(',')) {
const [from, to] = part.split('-')
const a = dayNumber(from)
if (a === undefined) return undefined
if (to === undefined) {
out.add(a)
continue
}
const b = dayNumber(to)
if (b === undefined || b < a) return undefined
for (let d = a; d <= b; d++) out.add(d)
}
return [...out].sort()
}
function listDays(ds: number[]): string {
if (ds.join() === '1,2,3,4,5') return 'weekday'
if (ds.join() === '0,6') return 'weekend day'
const names = ds.map((d) => DAY_NAMES[d])
return names.length === 1
? names[0]
: `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
/**
* A plain-English reading of a Windmill cron expression — "Every hour",
* "Every day at 4:00", "Every Monday at 9:30" — or undefined when it is not one
* of the common shapes, so the caller can show the expression itself.
*
* Windmill crons have six fields, seconds first; a five-field entry is padded
* at the end the way the schedule editor saves it (`formatCron`).
*/
export function describeCron(cron: string): string | undefined {
const fields = formatCron(cron.trim()).split(/\s+/)
if (fields.length !== 6) return undefined
const [sec, min, hour, dom, month, dow] = fields
if (month !== '*') return undefined
const anyDay = dom === '*' && (dow === '*' || dow === '?')
if (anyDay && min === '*' && hour === '*') {
if (sec === '*') return 'Every second'
const n = everyN(sec)
if (n) return `Every ${n} seconds`
}
if (sec !== '0') return undefined
if (anyDay) {
if (hour === '*') {
if (min === '*') return 'Every minute'
const n = everyN(min)
if (n) return `Every ${n} minutes`
if (min === '0') return 'Every hour'
if (isNum(min)) return `Every hour at :${min.padStart(2, '0')}`
return undefined
}
const n = everyN(hour)
if (n && min === '0') return `Every ${n} hours`
if (isNum(hour) && isNum(min)) return `Every day at ${time(hour, min)}`
return undefined
}
if (!isNum(hour) || !isNum(min)) return undefined
if (dom === '*') {
const ds = days(dow)
return ds ? `Every ${listDays(ds)} at ${time(hour, min)}` : undefined
}
if (isNum(dom) && (dow === '*' || dow === '?')) {
return `Every month on the ${ordinal(Number(dom))} at ${time(hour, min)}`
}
return undefined
}
@@ -1363,7 +1363,7 @@
withSummaries(mode === 'edit' || includeDrafts ? graphWithDraft : deployedGraph)
)
// Adds what the canvas shows but the resolved graph does not carry: script
// summaries and languages, and which assets are misconfigured.
// summaries and languages, schedule crons, and which nodes are misconfigured.
function withSummaries(g: AssetGraphResponse): AssetGraphResponse {
const showDrafts = mode === 'edit' || includeDrafts
const explicitOnByPath = new Map<string, Array<{ kind: AssetKind; path: string }>>()
@@ -1381,8 +1381,16 @@
if (refs.length > 0) explicitOnByPath.set(r.path, refs)
}
const errors = pipelineNodeErrors(g, explicitOnByPath)
const crons = scheduleCrons.current
return {
...g,
triggers: crons?.size
? g.triggers.map((t) =>
t.trigger_kind === 'schedule' && t.path && !t.schedule && crons.has(t.path)
? { ...t, schedule: crons.get(t.path) }
: t
)
: g.triggers,
assets:
errors.assets.size === 0
? g.assets
@@ -2459,6 +2467,31 @@
}
)
// Crons 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 scheduleCrons = resource(
[() => $workspaceStore, () => graphRes.current],
async ([ws, g]) => {
const crons = new Map<string, string>()
if (!ws || !g) return crons
const paths = new Set<string>()
for (const t of g.triggers) {
if (t.trigger_kind === 'schedule' && t.path && !t.missing) paths.add(t.path)
}
await Promise.all(
[...paths].map(async (path) => {
try {
const s = await ScheduleService.getSchedule({ workspace: ws, path })
crons.set(path, s.schedule)
} catch {
// Unreadable schedule: its node keeps the plain "Schedule" label.
}
})
)
return crons
}
)
// Folder whose graph is actually rendered. `graphRes.current` is stale-
// while-revalidate on an in-place folder switch, so keying the canvas's
// one-shot initial fit on the route param would fire the new folder's fit