fix: fire a one-time schedule on the exact chosen date and time (#1497)

Simple "Once" schedules compiled to a 5-field cron, which has no year field, so
the scheduler computed the next run as the next annual occurrence. A date chosen
for a later year ran a year early, and a time already elapsed today ran a year
late, contradicting the UI promise that the task fires on the chosen date.

One-time schedules now send the chosen absolute timestamp (run_at) and the
backend pins next_run_at to it instead of the cron-derived next run, so the run
fires on the exact selected instant including the year. The Simple-mode
validation now compares the full chosen instant against the current time, so a
time earlier today is rejected as past rather than silently deferred a year.
run_at is validated as a finite, future epoch-millisecond timestamp on create
and update. The enable/disable toggle preserves a one-shot's pinned next_run_at
(its yearless cron cannot reconstruct the chosen year), so re-enabling restores
the exact instant. Recurring shapes and Advanced mode are unchanged (cron stays
authoritative).
This commit is contained in:
Anso
2026-06-28 04:31:16 -04:00
committed by GitHub
parent a6d431f0d7
commit cf0db36e78
5 changed files with 222 additions and 9 deletions
@@ -22,6 +22,7 @@ import {
buildCron,
parseCron,
getSimpleScheduleError,
getOnceRunAt,
type SimpleSchedule,
} from '@/lib/scheduling';
import { ScheduleSimplePanel } from './ScheduleSimplePanel';
@@ -277,6 +278,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
// backend stores; Advanced mode sends the raw expression as-is.
const cronExpression = scheduleMode === 'simple' ? buildCron(simpleSchedule) : formCron;
// A one-time ('once') Simple schedule pins its exact run instant (including
// year) via run_at, because the 5-field cron cannot encode a year. null for
// every recurring shape and for Advanced mode, where the cron is authoritative.
const runAt = scheduleMode === 'simple' ? getOnceRunAt(simpleSchedule) : null;
const body: Record<string, unknown> = {
name: formName,
target_type: actionDef.targetType,
@@ -284,6 +290,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
cron_expression: cronExpression,
enabled: formEnabled,
delete_after_run: formDeleteAfterRun,
run_at: runAt,
target_id: actionDef.requiresStack ? formTargetId : null,
node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null,
prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null,