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,
+31 -1
View File
@@ -4,6 +4,7 @@ import {
buildCron,
parseCron,
getSimpleScheduleError,
getOnceRunAt,
type SimpleSchedule,
} from './scheduling';
@@ -140,6 +141,24 @@ describe('parseCron', () => {
});
});
describe('getOnceRunAt', () => {
it('returns the exact picked instant (date + chosen time) for a once schedule', () => {
const runAt = getOnceRunAt(schedule({ frequency: 'once', minute: 30, hour: 23, date: new Date(2027, 6, 1) }));
expect(runAt).toBe(new Date(2027, 6, 1, 23, 30, 0, 0).getTime());
});
it('preserves the selected year so a future-year once does not collapse to this year', () => {
const runAt = getOnceRunAt(schedule({ frequency: 'once', minute: 0, hour: 9, date: new Date(2030, 0, 15) }));
expect(new Date(runAt as number).getFullYear()).toBe(2030);
});
it('returns null for a once schedule with no date and for recurring frequencies', () => {
expect(getOnceRunAt(schedule({ frequency: 'once', date: null }))).toBeNull();
expect(getOnceRunAt(schedule({ frequency: 'daily' }))).toBeNull();
expect(getOnceRunAt(schedule({ frequency: 'monthly', dayOfMonth: 5 }))).toBeNull();
});
});
describe('getSimpleScheduleError', () => {
const now = new Date(2026, 5, 28);
@@ -162,13 +181,24 @@ describe('getSimpleScheduleError', () => {
it('blocks once with a past date', () => {
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 5, 1) }), now))
.toBe('The selected date is in the past and this schedule would never fire.');
.toBe('The selected date and time are in the past and this schedule would never fire.');
});
it('passes once with a future date', () => {
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 11, 25) }), now)).toBeNull();
});
it('blocks once for a time earlier today that has already passed', () => {
const noon = new Date(2026, 5, 28, 12, 0);
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 5, 28), hour: 1, minute: 0 }), noon))
.toBe('The selected date and time are in the past and this schedule would never fire.');
});
it('passes once for a later time today that has not yet passed', () => {
const noon = new Date(2026, 5, 28, 12, 0);
expect(getSimpleScheduleError(schedule({ frequency: 'once', date: new Date(2026, 5, 28), hour: 23, minute: 0 }), noon)).toBeNull();
});
it('blocks an invalid time', () => {
expect(getSimpleScheduleError(schedule({ frequency: 'daily', minute: Number.NaN }), now)).toBe('Enter a valid time.');
});
+21 -3
View File
@@ -70,6 +70,19 @@ export function buildCron(s: SimpleSchedule): string {
}
}
/**
* Absolute epoch-ms fire time for a one-time ('once') schedule: the picked date
* at the chosen hour and minute. Returns null when the schedule is not 'once' or
* has no date. A 5-field cron cannot encode a year, so a one-shot sends this
* explicit timestamp to the backend to pin the exact run (year and time of day);
* relying on the cron alone fires on the next annual occurrence, which can be a
* different year than the date the admin selected.
*/
export function getOnceRunAt(s: SimpleSchedule): number | null {
if (s.frequency !== 'once' || !s.date) return null;
return new Date(s.date.getFullYear(), s.date.getMonth(), s.date.getDate(), s.hour, s.minute, 0, 0).getTime();
}
function parseIntField(field: string, min: number, max: number): number | null {
if (!/^\d+$/.test(field)) return null;
const n = Number(field);
@@ -167,9 +180,14 @@ export function getSimpleScheduleError(s: SimpleSchedule, now: Date = new Date()
}
if (s.frequency === 'once') {
if (!s.date) return 'Select a date.';
const picked = new Date(s.date.getFullYear(), s.date.getMonth(), s.date.getDate()).getTime();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
if (picked < today) return 'The selected date is in the past and this schedule would never fire.';
// Compare the full chosen instant (date + time), not just the day: a time
// earlier today has already passed. The backend also rejects a past run_at
// with a 400; this is the friendlier, save-blocking guard surfaced before
// the request is sent.
const when = getOnceRunAt(s);
if (when !== null && when <= now.getTime()) {
return 'The selected date and time are in the past and this schedule would never fire.';
}
}
return null;
}