feat: Custom Cron Cadence for Scheduled Chats (#15084)

* feat: custom cron cadence for scheduled chats

Scheduled chats could only be built from four fixed presets, each pinned to a
single hour and minute, so anything outside that shape (twice a day, every 15
minutes, the 1st of the month) was not expressible. This adds a Custom cadence
that takes a raw five-field cron expression.

The cadence schema becomes a discriminated union on `frequency`. A cron row
carries `expression` instead of the hour and minute it cannot represent, since
there is no single hour for `0 9,17 * * 1-5`, and the Mongo schema requires each
field only for the shape that has it: a blanket `required` would reject every
cron write, and dropping it entirely would let a structured cadence silently
fire at 00:00 with a missing hour.

Five fields only. croner also reads a six-field form carrying seconds and a
seven-field form that pins a year, and both are refused. Seconds would promise a
precision the engine does not keep, since it polls on a thirty-second tick and
offsets each schedule by up to two minutes of jitter. A pinned year makes a
cadence that runs out, and every place that computes a next run reads "no next
occurrence" as a cadence it cannot read.

Compilation, validation, next-run previews and interval measurement live in
packages/data-provider so the dialog and the engine share one parser and cannot
drift. The dialog previews the next occurrences, enforces the admin interval
floor and disables its own submit from the same functions the server validates
with, so it cannot offer a Create the API answers 400 to.

The interval floor now covers cron, and measures it twice, taking the smaller.
The nominal gap is probed in UTC and discounted by the same worst-case DST
allowance the structured branches carry, which keeps `0 9 * * *` reporting
exactly what the Daily preset reports. Real elapsed time is then measured in the
schedule's own zone across each of that zone's transitions, because
spring-forward compresses a gap that straddles one: `0 0,12 * * *` in
America/New_York is 11 hours that day, not 12, and a floor between the two would
otherwise be bypassed. The floor ships with the schedules list so the dialog can
mirror it rather than surfacing it as a 400 after submit.

Radio gains a wrap variant, since five frequency segments no longer fit one row
in a phone-width dialog and a translated label can push even a desktop one over.
Its indicator follows the selection across rows; the single-row default is
unchanged.

* fix: mark the cron input invalid when the interval floor rejects it

A floor-violating expression disabled Create and rendered the cadence
message, but the input itself still said aria-invalid=false and its
aria-describedby never reached that message, leaving a screen reader
user with a disabled Create and no stated reason.
This commit is contained in:
Marco Beretta 2026-08-23 20:35:18 +02:00 committed by GitHub
parent 0f376884bd
commit 44d97f859d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1542 additions and 206 deletions

View file

@ -3,7 +3,6 @@ import { v4 } from 'uuid';
import { Folder } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form';
import { PermissionBits, scheduleFrequencies } from 'librechat-data-provider';
import {
Input,
Label,
@ -17,6 +16,15 @@ import {
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import {
PermissionBits,
isCronCadence,
nextRunInstants,
scheduleFrequencies,
isValidCronExpression,
cadenceIntervalMinutes,
SCHEDULE_CRON_MAX_LENGTH,
} from 'librechat-data-provider';
import type {
TSchedule,
TCreateSchedule,
@ -32,7 +40,13 @@ import {
useCreateScheduleMutation,
useUpdateScheduleMutation,
} from '~/data-provider';
import { to12Hour, to24Hour, describeCadence, formatScheduleDay } from './cadence';
import {
to12Hour,
to24Hour,
describeCadence,
formatRunInstant,
formatScheduleDay,
} from './cadence';
import { useChatProjectPicker } from './useScheduleProjects';
import { VariableEditor } from '~/components/Variables';
import { useLocalize } from '~/hooks';
@ -56,6 +70,9 @@ type ScheduleFormValues = {
minute: number;
meridiem: Meridiem;
dayOfWeek: number;
/** Only read when `frequency` is `cron`; held across a switch away and back so a
* user who tries a preset does not lose the expression they typed. */
expression: string;
};
const FREQUENCY_LABELS: Record<ScheduleFrequency, TranslationKeys> = {
@ -63,10 +80,21 @@ const FREQUENCY_LABELS: Record<ScheduleFrequency, TranslationKeys> = {
daily: 'com_ui_schedule_daily',
weekdays: 'com_ui_schedule_weekdays',
weekly: 'com_ui_schedule_weekly',
cron: 'com_ui_schedule_cron',
};
const BASE_MINUTES = [0, 15, 30, 45];
/** Every weekday at 09:00: a recognisable starting point to edit rather than an
* empty field the user has to guess the field order from. */
const DEFAULT_CRON = '0 9 * * 1-5';
/** Enough previewed occurrences to show the SHAPE of a cadence (that `0 9,17 * * 1-5`
* fires twice a day), which a single row cannot. Kept small deliberately: the dialog
* turns scrolling off at `md` (see the template className below), so every preview
* row spends the same fixed height budget a form row does. */
const PREVIEW_RUN_COUNT = 3;
const FORM_ID = 'schedule-form';
const getDefaultValues = (schedule?: TSchedule): ScheduleFormValues => {
@ -81,28 +109,51 @@ const getDefaultValues = (schedule?: TSchedule): ScheduleFormValues => {
minute: 0,
meridiem: 'AM',
dayOfWeek: 1,
expression: DEFAULT_CRON,
};
}
const { hour12, meridiem } = to12Hour(schedule.cadence.hour);
return {
const identity = {
name: schedule.name,
prompt: schedule.prompt,
agent_id: schedule.agent_id,
chatProjectId: schedule.chatProjectId ?? '',
frequency: schedule.cadence.frequency,
};
const cadence = schedule.cadence;
if (isCronCadence(cadence)) {
// A cron row carries no hour or minute of its own, so the structured pickers keep
// their defaults: switching away from Custom then lands on a sensible time rather
// than on whatever the expression's first field happened to be.
return {
...identity,
frequency: 'cron',
hour12: 9,
minute: 0,
meridiem: 'AM',
dayOfWeek: 1,
expression: cadence.expression,
};
}
const { hour12, meridiem } = to12Hour(cadence.hour);
return {
...identity,
frequency: cadence.frequency,
hour12,
minute: schedule.cadence.minute,
minute: cadence.minute,
meridiem,
dayOfWeek: schedule.cadence.daysOfWeek?.[0] ?? 1,
dayOfWeek: cadence.daysOfWeek?.[0] ?? 1,
expression: DEFAULT_CRON,
};
};
type CadenceFormValues = Pick<
ScheduleFormValues,
'frequency' | 'hour12' | 'minute' | 'meridiem' | 'dayOfWeek'
'frequency' | 'hour12' | 'minute' | 'meridiem' | 'dayOfWeek' | 'expression'
>;
const buildCadence = (values: CadenceFormValues, overrideDays?: number[]): TScheduleCadence => {
if (values.frequency === 'cron') {
return { frequency: 'cron', expression: values.expression.trim() };
}
if (values.frequency === 'hourly') {
return { frequency: 'hourly', hour: 0, minute: values.minute };
}
@ -152,6 +203,7 @@ export default function ScheduleDialog({
const minute = watch('minute');
const meridiem = watch('meridiem');
const dayOfWeek = watch('dayOfWeek');
const expression = watch('expression');
const { data: agents } = useListAgentsQuery(
{ requiredPermission: PermissionBits.VIEW },
@ -168,6 +220,7 @@ export default function ScheduleDialog({
* enforces rather than a second, client-side interpretation of the config. */
const { data: schedulesData } = useSchedulesQuery();
const pinnedProjectId = schedulesData?.limits.projectId;
const minIntervalMinutes = schedulesData?.limits.minIntervalMinutes;
const requireProject = schedulesData?.limits.requireProject === true;
const {
items: loadedProjectItems,
@ -224,7 +277,7 @@ export default function ScheduleDialog({
const minuteOptions = useMemo(() => {
const minutes = new Set(BASE_MINUTES);
if (schedule) {
if (schedule && !isCronCadence(schedule.cadence)) {
minutes.add(schedule.cadence.minute);
}
return [...minutes]
@ -306,16 +359,21 @@ export default function ScheduleDialog({
? schedule.cadence.daysOfWeek
: undefined;
/** Whether this submit carries a cadence at all: a pure rename does not, and the
* API only validates a cadence it actually receives. Read by the interval floor
* below as well as by the PATCH payload. */
const cadenceTouched =
dirtyFields.frequency === true ||
dirtyFields.hour12 === true ||
dirtyFields.minute === true ||
dirtyFields.meridiem === true ||
dirtyFields.dayOfWeek === true ||
dirtyFields.expression === true;
const onSubmit = (values: ScheduleFormValues) => {
if (schedule) {
// Preserve the stored cadence entirely on a pure rename (no cadence control
// touched).
const cadenceTouched =
dirtyFields.frequency ||
dirtyFields.hour12 ||
dirtyFields.minute ||
dirtyFields.meridiem ||
dirtyFields.dayOfWeek;
const cadence = buildCadence(values, resolvePreservedWeeklyDays(values.frequency));
// PATCH only the fields the user actually touched, like the cadence handling
// above: submitting the whole form snapshot silently overwrites fields another
@ -386,11 +444,69 @@ export default function ScheduleDialog({
createSchedule.mutate(payload);
};
const summaryCadence = buildCadence(
{ frequency, hour12, minute, meridiem, dayOfWeek },
resolvePreservedWeeklyDays(frequency),
/** The stored multi-day set an untouched weekly picker keeps, flattened to a string
* so the memos below can depend on its VALUE: the array identity changes whenever
* the schedules query polls, which would re-walk croner on every refresh. */
const preservedWeeklyDays = resolvePreservedWeeklyDays(frequency)?.join(',') ?? '';
/** Read by the summary, the preview and the interval floor, each of which walks
* croner. Memoized so a name or prompt keystroke does not re-derive all three. */
const previewCadence = useMemo(
() =>
buildCadence(
{ frequency, hour12, minute, meridiem, dayOfWeek, expression },
preservedWeeklyDays === '' ? undefined : preservedWeeklyDays.split(',').map(Number),
),
[frequency, hour12, minute, meridiem, dayOfWeek, expression, preservedWeeklyDays],
);
const summary = `${describeCadence(summaryCadence, localize, locale)} · ${timezone}`;
/** Validated in the schedule's own timezone, the same argument the server passes, so
* a zone-sensitive expression cannot pass here and be refused there. */
/** Memoized like every derivation around it: `watch()` re-renders the dialog on
* each keystroke in ANY field, and this walks croner. */
const cronIsValid = useMemo(
() => frequency !== 'cron' || isValidCronExpression(expression.trim(), timezone),
[frequency, expression, timezone],
);
const previewRuns = useMemo(
() =>
frequency === 'cron' && cronIsValid
? nextRunInstants(previewCadence, timezone, PREVIEW_RUN_COUNT)
: [],
[frequency, cronIsValid, previewCadence, timezone],
);
/** The floor binds exactly where the API binds it: to a cadence that will actually
* be submitted, and to any edit leaving the schedule enabled. Renaming a DISABLED
* schedule whose stored cadence predates a raised floor is a valid maintenance edit
* the API accepts, so the dialog must not hold it hostage to a timing change. */
const floorApplies = schedule == null || cadenceTouched || schedule.enabled;
/** Checked against the SAME function the server enforces with, so the dialog never
* offers a submit the API would answer 400 to. Only meaningful once the expression
* parses: an unfireable one reports a zero-minute gap, which would otherwise
* surface as "too frequent" instead of "never runs". Memoized because the cron
* branch walks 32 occurrences to find its tightest gap. */
const belowFloor = useMemo(
() =>
floorApplies &&
cronIsValid &&
minIntervalMinutes != null &&
// The zone is load-bearing, not decoration: without it this measures the nominal
// gap while the API measures the DST-compressed one, and the dialog offers a
// Create the API then rejects.
cadenceIntervalMinutes(previewCadence, timezone) < minIntervalMinutes,
[floorApplies, cronIsValid, minIntervalMinutes, previewCadence, timezone],
);
const cadenceError =
belowFloor && minIntervalMinutes != null
? localize('com_ui_schedule_min_interval', { minutes: minIntervalMinutes })
: null;
const canSubmit = cronIsValid && !belowFloor && !isLoading;
const summary = `${describeCadence(previewCadence, localize, locale)} · ${timezone}`;
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
@ -604,115 +720,171 @@ export default function ScheduleDialog({
value={field.value}
onChange={(value) => field.onChange(value as ScheduleFrequency)}
fullWidth
// Five segments no longer fit one row in a phone-width dialog, and
// a translated label can push even a desktop one over.
wrap
aria-labelledby="schedule-frequency-label"
/>
)}
/>
</fieldset>
<div className="grid gap-4 md:grid-cols-2">
{frequency === 'weekly' && (
<fieldset className="space-y-2">
<legend>
<Label
id="schedule-day-label"
className="text-sm font-medium text-text-primary"
>
{localize('com_ui_schedule_day')}
</Label>
</legend>
<Controller
name="dayOfWeek"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={dayOptions}
variant="field"
portal={false}
aria-labelledby="schedule-day-label"
testId="schedule-day-select"
/>
)}
/>
</fieldset>
)}
<fieldset className="space-y-2">
<legend>
<Label id="schedule-time-label" className="text-sm font-medium text-text-primary">
{localize(
frequency === 'hourly'
? 'com_ui_schedule_minutes_past_hour'
: 'com_ui_schedule_time',
)}
</Label>
</legend>
<div
className={cn(
'grid gap-2',
frequency === 'hourly' ? 'max-w-[8rem] grid-cols-1' : 'grid-cols-3',
)}
>
{frequency !== 'hourly' && (
{frequency === 'cron' ? (
<div className="space-y-2">
<Label htmlFor="schedule-cron" className="text-sm font-medium text-text-primary">
{localize('com_ui_schedule_cron_expression')}
</Label>
<Input
id="schedule-cron"
className="w-full font-mono"
spellCheck={false}
autoComplete="off"
placeholder={DEFAULT_CRON}
maxLength={SCHEDULE_CRON_MAX_LENGTH}
aria-invalid={!cronIsValid || belowFloor}
aria-describedby={
// The floor message renders under the summary as
// `schedule-cadence-message`; a floor-violating expression must
// still mark THIS input invalid and point at that message, or a
// screen reader finds a disabled Create with no stated reason.
belowFloor
? 'schedule-cron-hint schedule-cron-message schedule-cadence-message'
: 'schedule-cron-hint schedule-cron-message'
}
data-testid="schedule-cron-input"
{...register('expression')}
/>
<p id="schedule-cron-hint" className="text-xs text-text-secondary">
{localize('com_ui_schedule_cron_hint')}
</p>
<FieldMessage
id="schedule-cron-message"
message={cronIsValid ? undefined : localize('com_ui_schedule_cron_invalid')}
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{frequency === 'weekly' && (
<fieldset className="space-y-2">
<legend>
<Label
id="schedule-day-label"
className="text-sm font-medium text-text-primary"
>
{localize('com_ui_schedule_day')}
</Label>
</legend>
<Controller
name="hour12"
name="dayOfWeek"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={hourOptions}
options={dayOptions}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_hour')}
testId="schedule-hour-select"
aria-labelledby="schedule-day-label"
testId="schedule-day-select"
/>
)}
/>
)}
<Controller
name="minute"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={minuteOptions}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_minute')}
testId="schedule-minute-select"
</fieldset>
)}
<fieldset className="space-y-2">
<legend>
<Label
id="schedule-time-label"
className="text-sm font-medium text-text-primary"
>
{localize(
frequency === 'hourly'
? 'com_ui_schedule_minutes_past_hour'
: 'com_ui_schedule_time',
)}
</Label>
</legend>
<div
className={cn(
'grid gap-2',
frequency === 'hourly' ? 'max-w-[8rem] grid-cols-1' : 'grid-cols-3',
)}
>
{frequency !== 'hourly' && (
<Controller
name="hour12"
control={control}
render={({ field }) => (
<Dropdown
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={hourOptions}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_hour')}
testId="schedule-hour-select"
/>
)}
/>
)}
/>
{frequency !== 'hourly' && (
<Controller
name="meridiem"
name="minute"
control={control}
render={({ field }) => (
<Dropdown
value={field.value}
onChange={field.onChange}
options={meridiemOptions}
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={minuteOptions}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_meridiem')}
testId="schedule-meridiem-select"
ariaLabel={localize('com_ui_schedule_minute')}
testId="schedule-minute-select"
/>
)}
/>
)}
</div>
</fieldset>
</div>
{frequency !== 'hourly' && (
<Controller
name="meridiem"
control={control}
render={({ field }) => (
<Dropdown
value={field.value}
onChange={field.onChange}
options={meridiemOptions}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_meridiem')}
testId="schedule-meridiem-select"
/>
)}
/>
)}
</div>
</fieldset>
</div>
)}
<p
className="break-words rounded-lg bg-surface-secondary px-3 py-2 text-sm text-text-secondary"
data-testid="schedule-summary"
>
{summary}
</p>
<div className="space-y-2">
<p
className="break-words rounded-lg bg-surface-secondary px-3 py-2 text-sm text-text-secondary"
data-testid="schedule-summary"
>
{summary}
</p>
<FieldMessage id="schedule-cadence-message" message={cadenceError ?? undefined} />
{previewRuns.length > 0 && (
<div className="space-y-1" data-testid="schedule-preview">
<p className="text-xs font-medium text-text-primary">
{localize('com_ui_schedule_next_runs')}
</p>
<ul className="space-y-0.5 text-xs text-text-secondary">
{previewRuns.map((run) => (
<li key={run.getTime()}>{formatRunInstant(run, timezone, locale)}</li>
))}
</ul>
</div>
)}
</div>
</form>
}
buttons={
@ -720,7 +892,7 @@ export default function ScheduleDialog({
type="submit"
form={FORM_ID}
variant="submit"
disabled={isLoading}
disabled={!canSubmit}
aria-label={localize(schedule ? 'com_ui_save' : 'com_ui_create')}
>
{isLoading ? (

View file

@ -21,8 +21,14 @@ const mockMutate = jest.fn();
/** Server-resolved schedule policy for the render under test. The dialog reads it from
* the schedules list query, the same cache entry the panel populates. */
let mockLimits: { maxPerUser: number; requireProject: boolean; projectId?: string } = {
let mockLimits: {
maxPerUser: number;
minIntervalMinutes: number;
requireProject: boolean;
projectId?: string;
} = {
maxPerUser: 10,
minIntervalMinutes: 0,
requireProject: false,
};
@ -109,7 +115,7 @@ const fillRequiredFields = async (user: ReturnType<typeof userEvent.setup>) => {
describe('ScheduleDialog', () => {
afterEach(() => {
jest.clearAllMocks();
mockLimits = { maxPerUser: 10, requireProject: false };
mockLimits = { maxPerUser: 10, minIntervalMinutes: 0, requireProject: false };
mockFetchedProject = undefined;
});
@ -215,6 +221,117 @@ describe('ScheduleDialog', () => {
);
});
describe('custom cron cadence', () => {
it('swaps the structured time controls for an expression field', async () => {
const user = userEvent.setup();
renderDialog();
expect(screen.getByTestId('schedule-hour-select')).toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_cron' }));
expect(screen.queryByTestId('schedule-hour-select')).not.toBeInTheDocument();
expect(screen.queryByTestId('schedule-minute-select')).not.toBeInTheDocument();
expect(screen.getByTestId('schedule-cron-input')).toBeInTheDocument();
});
it('blocks submit on an expression that never runs', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_cron' }));
const field = screen.getByTestId('schedule-cron-input');
await user.clear(field);
// Syntactically valid, but February never has a 30th: nothing would ever fire.
await user.type(field, '0 9 30 2 *');
expect(field).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByText('com_ui_schedule_cron_invalid')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'com_ui_create' })).toBeDisabled();
});
it('refuses the seconds and year forms the engine cannot honour', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_cron' }));
const field = screen.getByTestId('schedule-cron-input');
await user.clear(field);
// A seconds field promises a precision a thirty-second tick with jitter cannot
// keep, and a pinned year makes a cadence that runs out.
await user.type(field, '0 0 9 * * 1-5');
expect(field).toHaveAttribute('aria-invalid', 'true');
await user.clear(field);
await user.type(field, '0 9 * * 1-5');
expect(field).toHaveAttribute('aria-invalid', 'false');
});
it('previews the next runs in the schedule timezone', async () => {
const user = userEvent.setup();
renderDialog(storedSchedule({ cadence: { frequency: 'cron', expression: '0 9 * * *' } }));
expect(screen.getByTestId('schedule-cron-input')).toHaveValue('0 9 * * *');
const preview = await screen.findByTestId('schedule-preview');
// Three occurrences, each rendered at 9 AM in America/New_York rather than at
// whatever that instant reads as in the browser's own zone.
expect(within(preview).getAllByRole('listitem')).toHaveLength(3);
for (const item of within(preview).getAllByRole('listitem')) {
expect(item).toHaveTextContent(/9:00\s*AM/i);
}
await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_daily' }));
expect(screen.queryByTestId('schedule-preview')).not.toBeInTheDocument();
});
it('submits the expression as a cron cadence', async () => {
const user = userEvent.setup();
renderDialog();
await fillRequiredFields(user);
await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_cron' }));
const field = screen.getByTestId('schedule-cron-input');
await user.clear(field);
await user.type(field, '0 9,17 * * 1-5');
await user.click(screen.getByRole('button', { name: 'com_ui_create' }));
await waitFor(() => expect(mockMutate).toHaveBeenCalled());
expect(mockMutate.mock.calls[0][0].cadence).toEqual({
frequency: 'cron',
expression: '0 9,17 * * 1-5',
});
});
it('refuses a cadence the interval floor would reject', async () => {
mockLimits = { maxPerUser: 10, minIntervalMinutes: 60, requireProject: false };
const user = userEvent.setup();
renderDialog();
await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_cron' }));
const field = screen.getByTestId('schedule-cron-input');
await user.clear(field);
await user.type(field, '*/15 * * * *');
expect(screen.getByText(/com_ui_schedule_min_interval/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'com_ui_create' })).toBeDisabled();
expect(field).toHaveAttribute('aria-invalid', 'true');
expect(field.getAttribute('aria-describedby')).toContain('schedule-cadence-message');
});
it('still lets a disabled schedule below the floor be renamed', async () => {
// The API accepts a rename that changes no timing, so the dialog must not hold
// a schedule hostage to a floor raised after it was created.
mockLimits = { maxPerUser: 10, minIntervalMinutes: 100_000, requireProject: false };
const user = userEvent.setup();
renderDialog(storedSchedule({ enabled: false }));
await user.type(screen.getByPlaceholderText('com_ui_schedule_name_placeholder'), ' v2');
const save = screen.getByRole('button', { name: 'com_ui_save' });
expect(save).toBeEnabled();
await user.click(save);
await waitFor(() => expect(mockMutate).toHaveBeenCalled());
expect(mockMutate.mock.calls[0][0].payload.cadence).toBeUndefined();
});
});
describe('project scope', () => {
it('submits the chosen project so its runs are filed there', async () => {
const user = userEvent.setup();
@ -250,7 +367,7 @@ describe('ScheduleDialog', () => {
/** The server refuses an unscoped create under this policy; blocking it here keeps
* the user from losing a filled-in form to a 400. */
it('blocks submission when the deployment requires a project', async () => {
mockLimits = { maxPerUser: 10, requireProject: true };
mockLimits = { maxPerUser: 10, minIntervalMinutes: 0, requireProject: true };
const user = userEvent.setup();
renderDialog();
await fillRequiredFields(user);
@ -282,7 +399,7 @@ describe('ScheduleDialog', () => {
/** There is no "no project" to offer when one is mandatory. */
it('offers no clearing option when a project is required', async () => {
mockLimits = { maxPerUser: 10, requireProject: true };
mockLimits = { maxPerUser: 10, minIntervalMinutes: 0, requireProject: true };
const user = userEvent.setup();
renderDialog(storedSchedule({ chatProjectId: 'proj-1' }));
@ -318,7 +435,7 @@ describe('ScheduleDialog', () => {
* project in the form made that unreachable and an owner with no projects at all
* could not edit the stopped schedule. */
it('lets a disabled unscoped schedule be edited while a project is required', async () => {
mockLimits = { maxPerUser: 10, requireProject: true };
mockLimits = { maxPerUser: 10, minIntervalMinutes: 0, requireProject: true };
const user = userEvent.setup();
renderDialog(storedSchedule({ enabled: false, chatProjectId: undefined }));
@ -333,7 +450,7 @@ describe('ScheduleDialog', () => {
/** An ENABLED schedule still has to satisfy the requirement. */
it('still requires a project when the edit leaves the schedule enabled', async () => {
mockLimits = { maxPerUser: 10, requireProject: true };
mockLimits = { maxPerUser: 10, minIntervalMinutes: 0, requireProject: true };
const user = userEvent.setup();
renderDialog(storedSchedule({ enabled: true, chatProjectId: undefined }));
@ -350,7 +467,12 @@ describe('ScheduleDialog', () => {
* sends nothing, leaving the pin authoritative even if it moved since the dialog
* opened. */
it('shows a pinned project as read-only and never sends it', async () => {
mockLimits = { maxPerUser: 10, requireProject: true, projectId: 'proj-2' };
mockLimits = {
maxPerUser: 10,
minIntervalMinutes: 0,
requireProject: true,
projectId: 'proj-2',
};
const user = userEvent.setup();
renderDialog();
await fillRequiredFields(user);

View file

@ -0,0 +1,35 @@
import type { TScheduleCadence } from 'librechat-data-provider';
import { describeCadence, formatRunInstant } from '../cadence';
const localize = (key: string, vars?: Record<string, unknown>) =>
vars ? `${key} ${JSON.stringify(vars)}` : key;
describe('describeCadence', () => {
it('shows a cron cadence as the expression the user typed', () => {
// Deliberately not translated into prose: a five-field expression can say things
// no sentence template covers, and a wrong summary of a cadence the user wrote
// themselves is worse than the expression they already understand.
const cadence: TScheduleCadence = { frequency: 'cron', expression: '0 9,17 * * 1-5' };
expect(describeCadence(cadence, localize, 'en-US')).toBe(
'com_ui_schedule_runs_cron {"expression":"0 9,17 * * 1-5"}',
);
});
it('still describes the structured cadences in prose', () => {
expect(
describeCadence({ frequency: 'daily', hour: 9, minute: 0 }, localize, 'en-US'),
).toContain('com_ui_schedule_runs_daily');
expect(
describeCadence({ frequency: 'hourly', hour: 0, minute: 5 }, localize, 'en-US'),
).toContain('"minute":"05"');
});
});
describe('formatRunInstant', () => {
it('renders a previewed occurrence in the schedule timezone, not the browser one', () => {
const instant = new Date('2026-01-15T21:05:00Z');
expect(formatRunInstant(instant, 'UTC', 'en-US')).toMatch(/9:05\s*PM/i);
// Five hours behind UTC in January, so the same instant is a different clock time.
expect(formatRunInstant(instant, 'America/New_York', 'en-US')).toMatch(/4:05\s*PM/i);
});
});

View file

@ -1,3 +1,4 @@
import { isCronCadence } from 'librechat-data-provider';
import type { TScheduleCadence } from 'librechat-data-provider';
import type { LocalizeFunction } from '~/common';
@ -35,6 +36,12 @@ export const describeCadence = (
localize: LocalizeFunction,
locale?: string,
): string => {
if (isCronCadence(cadence)) {
// Shown verbatim rather than translated into prose. A five-field expression can
// say things no sentence template covers, and a wrong summary of the cadence a
// user typed themselves is worse than the expression they already understand.
return localize('com_ui_schedule_runs_cron', { expression: cadence.expression });
}
const { frequency, hour, minute, daysOfWeek } = cadence;
if (frequency === 'hourly') {
return localize('com_ui_schedule_runs_hourly', {
@ -56,3 +63,14 @@ export const describeCadence = (
}
return localize('com_ui_schedule_runs_daily', { time });
};
/** One previewed occurrence, in the schedule's own zone. */
export const formatRunInstant = (date: Date, timezone: string, locale?: string): string =>
new Intl.DateTimeFormat(locale, {
timeZone: timezone,
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
}).format(date);

View file

@ -1863,6 +1863,10 @@
"com_ui_schedule_am": "AM",
"com_ui_schedule_conflict": "This schedule was changed elsewhere. Reopen it to edit the latest version.",
"com_ui_schedule_created": "Schedule created",
"com_ui_schedule_cron": "Custom",
"com_ui_schedule_cron_expression": "Cron expression",
"com_ui_schedule_cron_hint": "minute, hour, day of month, month, day of week",
"com_ui_schedule_cron_invalid": "This expression never runs. Check the field order and values.",
"com_ui_schedule_daily": "Daily",
"com_ui_schedule_day": "Day of week",
"com_ui_schedule_delete": "Delete schedule",
@ -1882,12 +1886,14 @@
"com_ui_schedule_last_run": "Last run",
"com_ui_schedule_last_run_failed": "Last run failed",
"com_ui_schedule_meridiem": "AM or PM",
"com_ui_schedule_min_interval": "Runs must be at least {{minutes}} minutes apart",
"com_ui_schedule_minute": "Minute",
"com_ui_schedule_minutes_past_hour": "Minutes past the hour",
"com_ui_schedule_name_placeholder": "Morning news briefing",
"com_ui_schedule_needs_approval": "Needs approval",
"com_ui_schedule_new": "New schedule",
"com_ui_schedule_next_run": "Next run {{time}}",
"com_ui_schedule_next_runs": "Next",
"com_ui_schedule_options": "Schedule options",
"com_ui_schedule_pm": "PM",
"com_ui_schedule_project_none": "No project",
@ -1896,6 +1902,7 @@
"com_ui_schedule_run_now_started": "Run started",
"com_ui_schedule_run_skipped": "Skipped",
"com_ui_schedule_run_started": "Running",
"com_ui_schedule_runs_cron": "Runs on cron {{expression}}",
"com_ui_schedule_runs_daily": "Runs daily at {{time}}",
"com_ui_schedule_runs_hourly": "Runs every hour at :{{minute}}",
"com_ui_schedule_runs_weekdays": "Runs weekdays at {{time}}",

1
package-lock.json generated
View file

@ -44590,6 +44590,7 @@
"license": "ISC",
"dependencies": {
"axios": "^1.16.0",
"croner": "^10.0.1",
"dayjs": "^1.11.13",
"js-yaml": "^4.3.1",
"re2js": "^2.8.6",

View file

@ -1,19 +1,27 @@
import type { TScheduleCadence } from 'librechat-data-provider';
import { SCHEDULE_CRON_MAX_LENGTH, nextRunInstants } from 'librechat-data-provider';
import type { TScheduleCadence, TStructuredCadence } from 'librechat-data-provider';
import {
cadenceToCron,
computeNextRunAt,
isValidTimezone,
scheduleJitterMs,
cadenceIntervalMinutes,
isValidCronExpression,
SCHEDULE_JITTER_WINDOW_MS,
} from './cadence';
const NEW_YORK = 'America/New_York';
/** The only zone that shifts two hours, which is where the allowance comes from. */
const TROLL = 'Antarctica/Troll';
function cadence(overrides: Partial<TScheduleCadence>): TScheduleCadence {
function cadence(overrides: Partial<TStructuredCadence>): TScheduleCadence {
return { frequency: 'daily', hour: 0, minute: 0, ...overrides };
}
function cronCadence(expression: string): TScheduleCadence {
return { frequency: 'cron', expression };
}
function wallClock(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
@ -320,3 +328,137 @@ describe('cadenceIntervalMinutes', () => {
);
});
});
describe('cron cadence', () => {
it('compiles to the expression verbatim', () => {
expect(cadenceToCron(cronCadence('0 9,17 * * 1-5'))).toBe('0 9,17 * * 1-5');
});
it('computes the next run from a cron expression', () => {
const next = computeNextRunAt({
cadence: cronCadence('0 9 * * *'),
timezone: NEW_YORK,
scheduleId: 'cron-next',
after: new Date('2026-03-01T00:00:00Z'),
disableJitter: true,
});
expect(next).not.toBeNull();
expect(wallClock(next as Date, NEW_YORK)).toBe('Sun 2026-03-01 09:00');
});
it('reports the tightest gap so a dense expression fails the floor', () => {
expect(cadenceIntervalMinutes(cronCadence('* * * * *'))).toBe(1);
expect(cadenceIntervalMinutes(cronCadence('*/30 * * * *'))).toBe(30);
});
it('matches the structured values for the equivalent expressions', () => {
// an expression a user could type instead of picking the preset must not be
// rejected by a floor the preset passes
expect(cadenceIntervalMinutes(cronCadence('0 * * * *'))).toBe(
cadenceIntervalMinutes(cadence({ frequency: 'hourly' })),
);
expect(cadenceIntervalMinutes(cronCadence('0 9 * * *'))).toBe(
cadenceIntervalMinutes(cadence({ frequency: 'daily', hour: 9 })),
);
expect(cadenceIntervalMinutes(cronCadence('0 9 * * 1-5'))).toBe(
cadenceIntervalMinutes(cadence({ frequency: 'weekdays', hour: 9 })),
);
});
it('fails closed on an expression the engine cannot fire', () => {
// 0 means "violates every floor", so an unfireable expression cannot be saved
expect(cadenceIntervalMinutes(cronCadence('not a cron'))).toBe(0);
// syntactically valid, but February never has a 30th
expect(cadenceIntervalMinutes(cronCadence('0 9 30 2 *'))).toBe(0);
});
it('measures the gap spring-forward compresses rather than the nominal one', () => {
// Midnight to noon is 11 real hours on the day America/New_York springs forward.
// Probed without the zone it reads as 12, and a floor set between the two would
// admit a schedule that genuinely breaks it once a year.
expect(cadenceIntervalMinutes(cronCadence('0 0,12 * * *'), NEW_YORK)).toBe(11 * 60);
expect(cadenceIntervalMinutes(cronCadence('0 0,12 * * *'), 'UTC')).toBe(12 * 60);
// 01:00 and 03:00 are an hour apart on that day, not two.
expect(cadenceIntervalMinutes(cronCadence('0 1,3 * * *'), NEW_YORK)).toBe(60);
});
it('keeps an hourly expression at 60 minutes in a DST zone', () => {
// croner repeats (Troll even reverses) the folded instant at spring-forward.
// Counted as a gap it reads as 0 or less and rejects every hourly cron there.
for (const zone of [NEW_YORK, 'UTC', 'Europe/Berlin', 'Australia/Sydney', TROLL]) {
expect(cadenceIntervalMinutes(cronCadence('0 * * * *'), zone)).toBe(60);
}
});
it('previews a spring-forward fold as one occurrence, not two', () => {
// croner folds the skipped 2:00 onto 3:00 on the day America/New_York springs
// forward, emitting the same instant twice. That is one firing (the engine's
// unique-occurrence index counts it that way), and a preview keyed by instant
// would render duplicate rows.
jest.useFakeTimers().setSystemTime(new Date('2027-03-13T12:00:00Z'));
try {
const runs = nextRunInstants(cronCadence('0 2,3 * * *'), NEW_YORK, 6);
const instants = runs.map((run) => run.getTime());
expect(new Set(instants).size).toBe(instants.length);
} finally {
jest.useRealTimers();
}
});
it('measures a transition that lands on the preceding UTC date', () => {
// Australia/Sydney turns over at 16:00 UTC the day BEFORE the local date it
// belongs to, so a scan rounded forward to the anchor's own UTC day excluded it
// and the gap it compressed went unmeasured. 2027-10-03 is that day locally.
jest.useFakeTimers().setSystemTime(new Date('2027-10-01T00:00:00Z'));
try {
const straddling = cronCadence('0 0,12 * * *');
expect(cadenceIntervalMinutes(straddling, 'Australia/Sydney')).toBe(11 * 60);
expect(cadenceIntervalMinutes(straddling, 'UTC')).toBe(12 * 60);
} finally {
jest.useRealTimers();
}
});
it('refuses the seconds and year forms croner would otherwise accept', () => {
// Five fields only. A seconds field promises a precision the engine does not keep
// (a thirty-second tick plus up to two minutes of jitter), and a pinned year makes
// a cadence that runs out, which every "no next occurrence" reader treats as a
// cadence it cannot read.
expect(isValidCronExpression('0 9 * * 1-5')).toBe(true);
expect(isValidCronExpression('0 0 9 * * 1-5')).toBe(false);
expect(isValidCronExpression('0 0 9 * * 1-5 2027')).toBe(false);
// croner's shorthand aliases are the same promise in fewer characters.
expect(isValidCronExpression('@daily')).toBe(false);
});
it('accepts an expression only when it can actually match', () => {
expect(isValidCronExpression('0 0 29 2 *')).toBe(true);
// syntactically valid, but February never has a 30th: nothing would ever fire
expect(isValidCronExpression('0 9 30 2 *')).toBe(false);
expect(isValidCronExpression('not a cron')).toBe(false);
// croner accepts an unusable timezone and only throws when it computes with it
expect(isValidCronExpression('0 9 * * *', 'Not/AZone')).toBe(false);
});
it('refuses an expression longer than the schema stores', () => {
// A parseable expression over the cap is not "valid but large": the payload
// schema refuses it, so accepting it here left the dialog offering a Create the
// API answers 400 to, surfaced as a bare "something went wrong".
const minutes = Array.from({ length: 60 }, (_, minute) => minute).join(',');
const hours = Array.from({ length: 24 }, (_, hour) => hour).join(',');
const days = Array.from({ length: 31 }, (_, day) => day + 1).join(',');
const padded = `${minutes} ${hours} ${days} * *`;
expect(padded.length).toBeGreaterThan(SCHEDULE_CRON_MAX_LENGTH);
expect(isValidCronExpression(padded)).toBe(false);
// the same shape inside the cap still parses
expect(`${minutes} * * * *`.length).toBeLessThanOrEqual(SCHEDULE_CRON_MAX_LENGTH);
expect(isValidCronExpression(`${minutes} * * * *`)).toBe(true);
});
it('validates expressions with the parser the engine fires from', () => {
expect(isValidCronExpression('0 9,17 * * *')).toBe(true);
expect(isValidCronExpression('0 0 1 * *')).toBe(true);
expect(isValidCronExpression('nonsense')).toBe(false);
expect(isValidCronExpression('99 * * * *')).toBe(false);
});
});

View file

@ -1,39 +1,19 @@
import { Cron } from 'croner';
import {
cadenceToCron,
cadenceIntervalMinutes,
isValidCronExpression,
} from 'librechat-data-provider';
import type { TScheduleCadence } from 'librechat-data-provider';
export const SCHEDULE_JITTER_WINDOW_MS = 120_000;
/**
* Spring-forward compresses consecutive wall-clock occurrences, so the ENFORCEABLE
* minimum for day-and-longer gaps is the nominal gap minus the largest real-world
* transition: two hours (Antarctica/Troll; every other zone shifts at most one).
* A floor set exactly at the nominal value would otherwise admit a schedule that
* genuinely violates it once a year. Hourly gaps are unaffected (the skipped hours
* lengthen, never shorten, the gap between occurrences).
* Cadence compilation and the interval floor live in `librechat-data-provider` so
* the dialog validates against the exact rules this engine enforces. Re-exported
* here to keep the engine's imports pointed at one schedules module.
*/
const DST_COMPRESSION_MINUTES = 120;
const WEEKLY_DEFAULT_DAY = 1;
/**
* Compiles a structured cadence to a 5-field cron expression. The cadence
* object stays canonical (UI-native, no cron round-tripping); cron exists only
* as croner's input.
*/
export function cadenceToCron(cadence: TScheduleCadence): string {
const { frequency, hour, minute } = cadence;
if (frequency === 'hourly') {
return `${minute} * * * *`;
}
if (frequency === 'daily') {
return `${minute} ${hour} * * *`;
}
if (frequency === 'weekdays') {
return `${minute} ${hour} * * 1-5`;
}
const days = cadence.daysOfWeek?.length ? cadence.daysOfWeek : [WEEKLY_DEFAULT_DAY];
return `${minute} ${hour} * * ${[...days].sort((a, b) => a - b).join(',')}`;
}
export { cadenceToCron, cadenceIntervalMinutes, isValidCronExpression };
export function isValidTimezone(timezone: string): boolean {
try {
@ -44,34 +24,6 @@ export function isValidTimezone(timezone: string): boolean {
}
}
/** Minimum minutes between occurrences, for the admin interval floor. */
export function cadenceIntervalMinutes(cadence: TScheduleCadence): number {
if (cadence.frequency === 'hourly') {
return 60;
}
if (cadence.frequency === 'daily' || cadence.frequency === 'weekdays') {
return 24 * 60 - DST_COMPRESSION_MINUTES;
}
// Deduped defensively: the payload schema normalizes new writes, but a legacy
// stored [1, 1] would otherwise read as a zero-day gap and fail every floor.
const days = cadence.daysOfWeek?.length
? Array.from(new Set(cadence.daysOfWeek))
: [WEEKLY_DEFAULT_DAY];
if (days.length <= 1) {
return 7 * 24 * 60 - DST_COMPRESSION_MINUTES;
}
// The interval floor must reflect the SHORTEST gap between selected days
// (incl. the week wrap-around), not the average — e.g. [Mon, Tue] fires 24h
// apart, so it must be rejected against a >1440-minute floor.
const sorted = [...days].sort((a, b) => a - b);
let minGapDays = 7;
for (let i = 0; i < sorted.length; i++) {
const gap = i + 1 < sorted.length ? sorted[i + 1] - sorted[i] : 7 - sorted[i] + sorted[0];
minGapDays = Math.min(minGapDays, gap);
}
return minGapDays * 24 * 60 - DST_COMPRESSION_MINUTES;
}
/**
* Deterministic per-schedule jitter so fleet-wide fire spikes (everyone at
* 9:00) spread across a window while each schedule's displayed next-run time

View file

@ -270,6 +270,23 @@ describe('fireSchedule', () => {
expect([...runs.values()][0].status).toBe('started');
});
it('refuses to fire a cadence the engine cannot read at all', async () => {
// A cron cadence reaches the fire path with a stored timezone croner cannot use,
// so the next run is uncomputable: disable without dispatching.
const { methods, runs } = makeMethods();
mockFetch(async () => okResponse());
const schedule = makeSchedule({
cadence: { frequency: 'cron', expression: '0 9 * * *' },
timezone: 'Not/AZone',
});
const result = await fireSchedule(makeDeps(methods), schedule, LIMITS, dueAt());
expect(result.fired).toBe(false);
expect(runs.size).toBe(0);
expect(methods.disableSchedule).toHaveBeenCalledWith('sched-1', 'invalid_schedule', 'ct-1');
});
it('releases the old holder when a post-enqueue owner edit fences the advance', async () => {
const { methods } = makeMethods();
const enqueueTrigger = jest.fn(async () => undefined);

View file

@ -258,7 +258,12 @@ export async function fireSchedule(
// Enforce a raised interval floor at fire time: create/update reject too-frequent
// cadences, but an admin raising the floor later must also stop an already-enabled
// schedule that now runs more often than policy allows.
if (cadenceIntervalMinutes(schedule.cadence) < ownerLimits.minIntervalMinutes) {
// The schedule's own zone, because a cron cadence's tightest gap is a wall-clock
// question: spring-forward compresses a pair that straddles it, and the structured
// branches ignore the argument entirely.
if (
cadenceIntervalMinutes(schedule.cadence, schedule.timezone) < ownerLimits.minIntervalMinutes
) {
await methods.disableSchedule(schedule.id, 'invalid_schedule', claimToken);
await advance();
return { fired: false, skipped: 'disabled' as const };

View file

@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import type { ISchedule } from '@librechat/data-schemas';
import type { Response } from 'express';
import type { SchedulesHandlersDeps } from './handlers';
@ -162,6 +163,7 @@ function makeCreateDeps(over: Partial<SchedulesHandlersDeps> = {}): SchedulesHan
minIntervalMinutes: 60,
autoDisableAfterFailures: 5,
fireConcurrency: 5,
requireProject: false,
}),
canViewAgent: async () => true,
filterOwnedFileIds: async (ids: string[]) => ids,
@ -359,6 +361,154 @@ describe('createSchedule late-create compensation', () => {
});
});
describe('computeCreateDigest cadence shape', () => {
it('hashes a structured cadence exactly as it did before cron existed', () => {
// The digest is the idempotency key's content fence. Widening the canonical shape
// for every cadence would change it for schedules already out there: a create that
// committed before a deploy and lost its response would retry against a digest
// that no longer matches and be refused as if the key had been reused.
const legacy = createHash('sha256')
.update(
JSON.stringify({
name: 'Digest',
prompt: 'Summarize',
agent_id: 'agent-1',
timezone: 'America/New_York',
target: 'new',
enabled: true,
cadence: { frequency: 'daily', hour: 8, minute: 0, daysOfWeek: null },
file_ids: null,
}),
)
.digest('hex');
expect(createBodyDigest()).toBe(legacy);
});
it('hashes a cron cadence by its expression', () => {
const cron = computeCreateDigest({
...CREATE_BODY,
target: 'new',
enabled: true,
cadence: { frequency: 'cron', expression: '0 9 * * 1-5' },
} as never);
expect(cron).not.toBe(createBodyDigest());
});
});
describe('create with a cron cadence', () => {
it('resolves an idempotent replay after the floor was raised beneath it', async () => {
// The row committed under the old floor and the client lost the response. An
// admin raising the floor in between must not turn the retry into a 400 for a
// schedule that already exists; the raised floor reaches it at fire time.
const committed = {
id: 'sched-1',
clientRequestId: 'intent-1',
name: 'Digest',
prompt: 'Summarize',
agent_id: 'agent-1',
cadence: { frequency: 'daily', hour: 8, minute: 0 },
timezone: 'America/New_York',
target: 'new',
enabled: true,
} as unknown as ISchedule;
const deps = makeCreateDeps({
isUserDeleting: jest.fn(async () => false),
getLimits: async () => ({
enabled: true,
maxPerUser: 10,
// Above the ~1320 minutes a daily cadence reports, so the payload no longer
// clears the floor it was admitted under.
minIntervalMinutes: 100_000,
autoDisableAfterFailures: 5,
fireConcurrency: 5,
requireProject: false,
}),
});
(deps.methods.getScheduleByClientRequestId as jest.Mock).mockResolvedValue(committed);
const { res, captured } = makeRes();
await createSchedulesHandlers(deps).createSchedule(makeCreateReq(), res);
expect(captured.status).not.toBe(400);
expect(deps.methods.createScheduleWithSlot).not.toHaveBeenCalled();
});
it('still refuses a NEW create below the floor', async () => {
const deps = makeCreateDeps({
isUserDeleting: jest.fn(async () => false),
getLimits: async () => ({
enabled: true,
maxPerUser: 10,
minIntervalMinutes: 100_000,
autoDisableAfterFailures: 5,
fireConcurrency: 5,
requireProject: false,
}),
});
const { res, captured } = makeRes();
await createSchedulesHandlers(deps).createSchedule(makeCreateReq(), res);
expect(captured.status).toBe(400);
expect(deps.methods.createScheduleWithSlot).not.toHaveBeenCalled();
});
it('does not hold attachments for a create it deterministically refuses', async () => {
// retainFiles extends every upload's TTL to the 14-day schedule hold. Running it
// before a refusal nothing can retry past meant each attempt pinned uploads that
// no schedule will ever reference and nothing will ever release.
const markFilesUsed = jest.fn(async () => undefined);
const deps = makeCreateDeps({
isUserDeleting: jest.fn(async () => false),
markFilesUsed,
getLimits: async () => ({
enabled: true,
maxPerUser: 10,
minIntervalMinutes: 100_000,
autoDisableAfterFailures: 5,
fireConcurrency: 5,
requireProject: false,
}),
});
const { res, captured } = makeRes();
const req = {
body: { ...CREATE_BODY, file_ids: ['file-1'] },
user: { id: 'user-1', tenantId: 't1', role: 'USER' },
} as unknown as ServerRequest;
await createSchedulesHandlers(deps).createSchedule(req, res);
expect(captured.status).toBe(400);
expect(markFilesUsed).not.toHaveBeenCalled();
});
it('accepts a recurring expression that clears the floor', async () => {
// The default deps raise the account-deletion barrier on the post-insert
// re-check; this case is about the cadence, so keep that barrier down.
const deps = makeCreateDeps({ isUserDeleting: jest.fn(async () => false) });
(deps.methods.armSchedule as jest.Mock).mockResolvedValue(true);
// The response re-reads the row it just armed, so it has to exist to be returned.
(deps.methods.getScheduleById as jest.Mock).mockResolvedValue({
id: 'sched-1',
cadence: { frequency: 'cron', expression: '0 9 * * 1-5' },
timezone: 'America/New_York',
enabled: true,
});
const { res, captured } = makeRes();
const req = {
body: { ...CREATE_BODY, cadence: { frequency: 'cron', expression: '0 9 * * 1-5' } },
user: { id: 'user-1', tenantId: 't1', role: 'USER' },
} as unknown as ServerRequest;
await createSchedulesHandlers(deps).createSchedule(req, res);
expect(captured.status).toBe(201);
expect(deps.methods.armSchedule).toHaveBeenCalled();
});
});
describe('create idempotency', () => {
/** The row the FIRST attempt committed for CREATE_BODY, digest-less (legacy shape). */
const originalRow = (): ISchedule =>
@ -766,6 +916,108 @@ describe('attachment id deduplication', () => {
});
});
describe('updateSchedule cadence timezone resolution', () => {
const nyRow = (enabled: boolean) =>
({
id: 'sched-1',
enabled,
agent_id: 'agent-1',
cadence: { frequency: 'daily', hour: 8, minute: 0 },
timezone: 'America/New_York',
configRevision: 1,
}) as unknown as ISchedule;
const patchCadence = () =>
({
params: { id: 'sched-1' },
body: { cadence: { frequency: 'cron', expression: '0 0,12 * * *' } },
user: { id: 'user-1', tenantId: 't1', role: 'USER' },
}) as unknown as ServerRequest;
it('measures a cadence-only PATCH against the STORED timezone', async () => {
// The payload carries no timezone, so validation used undefined (i.e. UTC) and
// read the nominal 720-minute gap, while the schedule actually runs in New York
// where spring-forward compresses it to 660. The row stays disabled, so the later
// effective-cadence check never runs and nothing else catches it.
const deps = makeCreateDeps({
isUserDeleting: jest.fn(async () => false),
getLimits: async () => ({
enabled: true,
maxPerUser: 10,
minIntervalMinutes: 700,
autoDisableAfterFailures: 5,
fireConcurrency: 5,
requireProject: false,
}),
});
(deps.methods.getScheduleById as jest.Mock).mockResolvedValue(nyRow(false));
const { res, captured } = makeRes();
await createSchedulesHandlers(deps).updateSchedule(patchCadence(), res);
expect(captured.status).toBe(400);
expect(deps.methods.updateScheduleById).not.toHaveBeenCalled();
});
it('measures a timezone-ONLY patch against the floor, even while disabled', async () => {
// Timing is the cadence and the zone it is read in. Checking only a submitted
// CADENCE let a disabled row be retimed into a zone where its gap violates the
// floor: accepted with 200, then refused later at enable.
const deps = makeCreateDeps({
isUserDeleting: jest.fn(async () => false),
getLimits: async () => ({
enabled: true,
maxPerUser: 10,
minIntervalMinutes: 700,
autoDisableAfterFailures: 5,
fireConcurrency: 5,
requireProject: false,
}),
});
(deps.methods.getScheduleById as jest.Mock).mockResolvedValue({
...nyRow(false),
cadence: { frequency: 'cron', expression: '0 0,12 * * *' },
timezone: 'UTC',
} as unknown as ISchedule);
const { res, captured } = makeRes();
await createSchedulesHandlers(deps).updateSchedule(
{
params: { id: 'sched-1' },
body: { timezone: 'America/New_York' },
user: { id: 'user-1', tenantId: 't1', role: 'USER' },
} as unknown as ServerRequest,
res,
);
expect(captured.status).toBe(400);
expect(deps.methods.updateScheduleById).not.toHaveBeenCalled();
});
it('accepts the same PATCH where the stored zone does not compress it', async () => {
const deps = makeCreateDeps({
isUserDeleting: jest.fn(async () => false),
getLimits: async () => ({
enabled: true,
maxPerUser: 10,
minIntervalMinutes: 700,
autoDisableAfterFailures: 5,
fireConcurrency: 5,
requireProject: false,
}),
});
(deps.methods.getScheduleById as jest.Mock).mockResolvedValue({
...nyRow(false),
timezone: 'UTC',
} as unknown as ISchedule);
const { res, captured } = makeRes();
await createSchedulesHandlers(deps).updateSchedule(patchCadence(), res);
expect(captured.status ?? 200).toBe(200);
});
});
describe('updateSchedule re-enable attachment revalidation', () => {
const disabledWithFiles = () =>
({

View file

@ -1,7 +1,11 @@
import { logger } from '@librechat/data-schemas';
import { createHash, randomUUID } from 'node:crypto';
import { createSchedulePayloadSchema, updateSchedulePayloadSchema } from 'librechat-data-provider';
import type { TCreateSchedule, TUpdateSchedule } from 'librechat-data-provider';
import {
createSchedulePayloadSchema,
updateSchedulePayloadSchema,
isCronCadence,
} from 'librechat-data-provider';
import type { TScheduleCadence, TCreateSchedule, TUpdateSchedule } from 'librechat-data-provider';
import type { ScheduleMethods, ISchedule } from '@librechat/data-schemas';
import type { Response } from 'express';
import type {
@ -12,7 +16,12 @@ import type {
FireResult,
} from './types';
import type { ServerRequest } from '~/types';
import { isValidTimezone, cadenceIntervalMinutes, computeNextRunAt } from './cadence';
import {
isValidCronExpression,
cadenceIntervalMinutes,
computeNextRunAt,
isValidTimezone,
} from './cadence';
import { resolveScheduleProjectId } from './types';
export interface SchedulesHandlersDeps {
@ -113,12 +122,19 @@ export function computeCreateDigest(payload: TCreateSchedule): string {
timezone: payload.timezone,
target: payload.target,
enabled: payload.enabled,
cadence: {
frequency: payload.cadence.frequency,
hour: payload.cadence.hour,
minute: payload.cadence.minute,
daysOfWeek: payload.cadence.daysOfWeek ?? null,
},
// A structured cadence keeps EXACTLY the shape it hashed under before cron
// existed. Adding `expression: null` to it would change the canonical JSON, and
// with it the digest, for every schedule already out there: a create that
// committed before a deploy and lost its response would then retry against a
// digest that no longer matches and be refused as key reuse.
cadence: isCronCadence(payload.cadence)
? { frequency: 'cron' as const, expression: payload.cadence.expression }
: {
frequency: payload.cadence.frequency,
hour: payload.cadence.hour,
minute: payload.cadence.minute,
daysOfWeek: payload.cadence.daysOfWeek ?? null,
},
file_ids: payload.file_ids ?? null,
// `!== undefined`, NOT `!= null`: an OMITTED field still digests byte-identically
// to a payload from before project scope existed, so an in-flight create retried
@ -139,6 +155,31 @@ function sameList<T>(left: T[] | undefined, right: T[] | undefined): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/**
* Compares the fields that belong to the payload's cadence kind. A cron row has no
* hour or minute and a structured row has no expression, so comparing all of them
* unconditionally would read two identical cron rows as different (undefined vs
* undefined is fine, but a structured row's populated hour against a cron row's
* missing one is not) and resurface the duplicate this matching exists to prevent.
*/
function sameCadenceShape(
existing: ISchedule['cadence'] | undefined,
payload: TScheduleCadence,
): boolean {
if (existing == null) {
return false;
}
if (isCronCadence(payload)) {
return isCronCadence(existing) && existing.expression === payload.expression;
}
return (
!isCronCadence(existing) &&
existing.hour === payload.hour &&
existing.minute === payload.minute &&
sameList(existing.daysOfWeek, payload.daysOfWeek)
);
}
/**
* Legacy replay matching for rows stamped before `clientRequestDigest` existed.
* Deliberately omits `enabled` a policy auto-disable mutates it, and this
@ -152,9 +193,7 @@ function matchesCreatedSchedule(existing: ISchedule, payload: TCreateSchedule):
existing.timezone === payload.timezone &&
existing.target === payload.target &&
existing.cadence?.frequency === payload.cadence.frequency &&
existing.cadence?.hour === payload.cadence.hour &&
existing.cadence?.minute === payload.cadence.minute &&
sameList(existing.cadence?.daysOfWeek, payload.cadence.daysOfWeek) &&
sameCadenceShape(existing.cadence, payload.cadence) &&
sameList(existing.file_ids, payload.file_ids) &&
// Only when the payload names one: an operator pin is written to the row without
// the client ever sending it, and a legacy row predates project scope entirely.
@ -253,25 +292,61 @@ export interface SchedulesHandlers {
}
export function createSchedulesHandlers(deps: SchedulesHandlersDeps): SchedulesHandlers {
/**
* Wellformedness only: is this payload storable at all. Anything that depends on
* CURRENT policy, like the interval floor, is deliberately not here, because this
* runs before an idempotent create can resolve its existing row and a policy change
* would then refuse a retry of a schedule that already committed.
*
* `storedTimezone` is the row's own, for a PATCH that edits the cadence and leaves
* the timezone alone. Without it the cron below was validated against `undefined`,
* i.e. UTC, while the schedule actually runs in its stored zone.
*/
/**
* The interval floor, which is policy AT THIS MOMENT rather than a property of the
* payload. Applied only once a create is known to be a genuinely new row: an admin
* raising the floor between a create committing and its lost response being retried
* must not turn that retry into a 400 for a schedule that already exists.
*/
function withinIntervalFloor(
res: Response,
cadence: TScheduleCadence,
timezone: string,
limits: ScheduleLimits,
): boolean {
if (cadenceIntervalMinutes(cadence, timezone) >= limits.minIntervalMinutes) {
return true;
}
res.status(400).json({
error: `Schedule interval must be at least ${limits.minIntervalMinutes} minutes`,
});
return false;
}
async function validatePayload(
req: ServerRequest,
res: Response,
payload: TCreateSchedule | TUpdateSchedule,
limits: ScheduleLimits,
storedTimezone?: string,
): Promise<boolean> {
if (payload.timezone != null && !isValidTimezone(payload.timezone)) {
res.status(400).json({ error: 'Invalid IANA timezone' });
return false;
}
const timezone = payload.timezone ?? storedTimezone;
// Rejected here rather than left to `computeNextRunAt` returning null, which the
// engine reads as an unreadable cadence and disables, giving the user a saved
// schedule that never fires.
if (
payload.cadence != null &&
cadenceIntervalMinutes(payload.cadence) < limits.minIntervalMinutes
isCronCadence(payload.cadence) &&
!isValidCronExpression(payload.cadence.expression, timezone)
) {
res.status(400).json({
error: `Schedule interval must be at least ${limits.minIntervalMinutes} minutes`,
});
res.status(400).json({ error: 'Invalid cron expression' });
return false;
}
if (payload.agent_id != null && !(await deps.canViewAgent(payload.agent_id, req))) {
res.status(400).json({ error: 'Agent not found or not accessible' });
return false;
@ -411,6 +486,9 @@ export function createSchedulesHandlers(deps: SchedulesHandlersDeps): SchedulesH
schedules: schedules.map((schedule) => toWireSchedule(schedule, limits)),
limits: {
maxPerUser: limits.maxPerUser,
// minIntervalMinutes ships with the list so the dialog can refuse a cadence
// the floor would reject, instead of surfacing it as a 400 after submit.
minIntervalMinutes: limits.minIntervalMinutes,
requireProject: limits.requireProject,
...(limits.projectId != null && { projectId: limits.projectId }),
},
@ -554,11 +632,10 @@ export function createSchedulesHandlers(deps: SchedulesHandlersDeps): SchedulesH
});
return;
}
// Retain attachments BEFORE creating, so a persisted (claimable) schedule never
// references uploads still eligible for TTL expiry — there is no create-then-
// retain window where a crash or a failed rollback leaves the two inconsistent.
if (parsed.data.file_ids?.length && !(await retainFiles(parsed.data.file_ids, user.id))) {
res.status(500).json({ error: 'Failed to retain schedule attachments' });
// Past the replay lookup, so this is a genuinely new row and the current floor
// applies to it. BEFORE the next-run computation below, which is a croner walk
// wasted on a request this check refuses.
if (!withinIntervalFloor(res, parsed.data.cadence, parsed.data.timezone, limits)) {
return;
}
const id = `sched_${randomUUID()}`;
@ -569,6 +646,16 @@ export function createSchedulesHandlers(deps: SchedulesHandlersDeps): SchedulesH
scheduleId: id,
})
: undefined;
// Retain attachments BEFORE creating, so a persisted (claimable) schedule never
// references uploads still eligible for TTL expiry: that leaves no create-then-
// retain window where a crash or a failed rollback makes the two inconsistent.
// AFTER the floor check, for the same reason the capacity pre-check runs early:
// it is deterministic, so retaining first meant every retry of the same rejected
// payload extended the TTL of uploads no schedule will ever reference.
if (parsed.data.file_ids?.length && !(await retainFiles(parsed.data.file_ids, user.id))) {
res.status(500).json({ error: 'Failed to retain schedule attachments' });
return;
}
// Atomic cap: createScheduleWithSlot claims a free per-user slot via the
// {user, slot} partial unique index, so concurrent creates can never exceed
// maxPerUser. 'limit' means a concurrent racer took the last slot after the
@ -734,19 +821,23 @@ export function createSchedulesHandlers(deps: SchedulesHandlersDeps): SchedulesH
res.status(403).json({ error: 'Scheduled chats are disabled' });
return;
}
if (!(await validatePayload(req, res, parsed.data, limits))) {
if (!(await validatePayload(req, res, parsed.data, limits, existing.timezone))) {
return;
}
const cadence = parsed.data.cadence ?? existing.cadence;
const timezone = parsed.data.timezone ?? existing.timezone;
const enabled = parsed.data.enabled ?? existing.enabled;
// Re-validate the EFFECTIVE (possibly stored) cadence against the current
// floor whenever this edit leaves the schedule enabled — otherwise a bare
// {enabled:true} could re-enable an existing schedule that now runs too often.
if (enabled && cadenceIntervalMinutes(cadence) < limits.minIntervalMinutes) {
res.status(400).json({
error: `Schedule interval must be at least ${limits.minIntervalMinutes} minutes`,
});
// Timing is the CADENCE AND THE ZONE it is read in: the same expression is a
// different schedule in another zone, and `0 0,12 * * *` moved from UTC into
// America/New_York goes from a 12-hour gap to an 11-hour one on spring-forward
// day. A timezone-only PATCH therefore faces the floor exactly as a cadence one
// does, whatever the row's enabled state; checking only a SUBMITTED cadence let a
// disabled row be retimed under the floor and rejected later at enable.
const timingChanged = parsed.data.cadence != null || parsed.data.timezone != null;
// Measured on the EFFECTIVE pair, so a bare {enabled:true} still cannot re-enable
// a schedule that now runs too often, and a pure rename of a disabled row below a
// raised floor is still left alone: it changes no timing and the API accepts it.
if ((timingChanged || enabled) && !withinIntervalFloor(res, cadence, timezone, limits)) {
return;
}
// A supplied agent_id is validated in validatePayload; when an edit omits it

View file

@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import Radio from './Radio';
jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const FIVE = [
{ value: 'hourly', label: 'Hourly' },
{ value: 'daily', label: 'Daily' },
{ value: 'weekdays', label: 'Weekdays' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'cron', label: 'Custom' },
];
const group = (): HTMLElement => screen.getByRole('radiogroup');
describe('Radio', () => {
it('exposes every option as a radio and marks the selected one', () => {
render(<Radio options={FIVE} value="weekly" aria-labelledby="lbl" />);
expect(screen.getAllByRole('radio')).toHaveLength(5);
expect(screen.getByRole('radio', { name: 'Weekly' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('radio', { name: 'Daily' })).toHaveAttribute('aria-checked', 'false');
});
it('keeps the segments on one row by default', () => {
// The default must stay exactly as it was: every existing group relies on the
// single-row layout and its indicator stretched between the container's insets.
render(<Radio options={FIVE} value="daily" fullWidth />);
expect(group().className).not.toContain('flex-wrap');
});
it('lets the segments flow onto a second row when asked', () => {
// Each segment has a hard minimum width (px-4 plus a whitespace-nowrap label),
// so without this five of them overflow a phone-width dialog and the choices
// past the edge cannot be reached.
render(<Radio options={FIVE} value="daily" fullWidth wrap />);
expect(group().className).toContain('flex-wrap');
});
});

View file

@ -1,6 +1,9 @@
import React, { useState, useRef, useLayoutEffect, useCallback, memo } from 'react';
import { useLocalize } from '~/hooks';
/** Matches the `inset-y-1` the single-row indicator uses. */
const INDICATOR_INSET = 4;
interface Option {
value: string;
label: string;
@ -15,6 +18,12 @@ interface RadioProps {
className?: string;
buttonClassName?: string;
fullWidth?: boolean;
/** Lets the segments flow onto a second row instead of overflowing their
* container. A `whitespace-nowrap` label plus `px-4` gives every segment a hard
* minimum width, so five of them (translated labels are longer still) push past a
* dialog's width on a phone and the choices past the edge become unreachable.
* The moving indicator follows across rows; the single-row default is untouched. */
wrap?: boolean;
'aria-labelledby'?: string;
}
@ -26,6 +35,7 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
className = '',
buttonClassName = '',
fullWidth = false,
wrap = false,
'aria-labelledby': ariaLabelledBy,
}: RadioProps) {
const localize = useLocalize();
@ -49,11 +59,25 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
// offsetWidth/offsetLeft are layout metrics: unlike getBoundingClientRect they
// are not distorted by the dialog's open transform (scale), and they resolve to
// whole pixels, so the indicator matches its segment and keeps crisp borders.
if (!wrap) {
setBackgroundStyle({
width: `${selectedButton.offsetWidth}px`,
transform: `translateX(${selectedButton.offsetLeft}px)`,
});
return;
}
// Wrapped, the indicator also has to move vertically, so it carries its own
// height rather than stretching between the container's insets. INDICATOR_INSET
// reproduces the `inset-y-1` of the single-row default exactly, so switching a
// group to `wrap` does not change how it looks on a row that still fits.
setBackgroundStyle({
width: `${selectedButton.offsetWidth}px`,
transform: `translateX(${selectedButton.offsetLeft}px)`,
height: `${selectedButton.offsetHeight - INDICATOR_INSET * 2}px`,
transform: `translate(${selectedButton.offsetLeft}px, ${
selectedButton.offsetTop + INDICATOR_INSET
}px)`,
});
}, [currentValue, options]);
}, [currentValue, options, wrap]);
// Measure before paint and re-measure on any later layout change (the dialog's
// open animation settling, a window resize). A fixed timeout previously raced
@ -95,13 +119,17 @@ const Radio: React.NamedExoticComponent<RadioProps> = memo(function Radio({
return (
<div
ref={containerRef}
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-surface-tertiary px-1 ${className}`}
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} ${
wrap ? 'flex-wrap' : ''
} items-center rounded-lg bg-surface-tertiary px-1 ${className}`}
role="radiogroup"
aria-labelledby={ariaLabelledBy}
>
{selectedIndex >= 0 && isMounted && (
<div
className="pointer-events-none absolute inset-y-1 left-0 rounded-md border border-border-light bg-surface-primary shadow-sm transition-all duration-300 ease-out"
className={`pointer-events-none absolute left-0 rounded-md border border-border-light bg-surface-primary shadow-sm transition-all duration-300 ease-out ${
wrap ? 'top-0' : 'inset-y-1'
}`}
style={backgroundStyle}
/>
)}

View file

@ -44,6 +44,7 @@
"homepage": "https://librechat.ai",
"dependencies": {
"axios": "^1.16.0",
"croner": "^10.0.1",
"dayjs": "^1.11.13",
"js-yaml": "^4.3.1",
"re2js": "^2.8.6",

View file

@ -0,0 +1,351 @@
import { Cron } from 'croner';
import type { TScheduleCadence } from './types/schedules';
import { isCronCadence, SCHEDULE_CRON_MAX_LENGTH } from './types/schedules';
/** Mirrors the server default when a weekly cadence omits `daysOfWeek`. */
const WEEKLY_DEFAULT_DAY = 1;
/**
* Minute, hour, day of month, month, day of week. croner also reads a six-field form
* carrying seconds and a seven-field form that pins a year, and both are refused.
* Seconds would promise a precision the runtime does not keep: the engine polls on a
* thirty-second tick and offsets each schedule by up to two minutes of jitter. A pinned
* year makes a cadence that runs out, and every caller here treats "no next occurrence"
* as a cadence it cannot read.
*/
const CRON_FIELD_COUNT = 5;
/**
* Spring-forward compresses consecutive wall-clock occurrences, so the ENFORCEABLE
* minimum for day-and-longer gaps is the nominal gap minus the largest real-world
* transition: two hours (Antarctica/Troll; every other zone shifts at most one).
* A floor set exactly at the nominal value would otherwise admit a schedule that
* genuinely violates it once a year. Hourly gaps are unaffected (the skipped hours
* lengthen, never shorten, the gap between occurrences).
*/
const DST_COMPRESSION_MINUTES = 120;
/**
* Occurrences sampled when measuring a cron expression's tightest gap. The floor
* exists to reject expressions that fire too OFTEN, and a dense pattern reveals
* its short gap within the first few occurrences, so a small window answers the
* question this guards. Known limit: this is a bounded probe, not the exhaustive
* proof the structured formulas give. An expression that is sparse for the next
* `CRON_PROBE_OCCURRENCES` runs and dense later would pass here and be caught by
* the fire-time recheck instead.
*/
const CRON_PROBE_OCCURRENCES = 32;
/** Enough to span four nominal gaps from an anchor two gaps before a transition,
* which is what guarantees the straddling pair falls inside the window. */
const TRANSITION_PROBE_OCCURRENCES = 5;
/**
* Compiles a cadence to the cron expression the engine fires from. Shared rather
* than server-owned because the dialog previews the next runs, validates the
* interval floor, and disables its own submit from these same functions: a second
* client-side implementation would drift and either show run times the schedule
* does not keep or accept a cadence the server then rejects.
*/
export function cadenceToCron(cadence: TScheduleCadence): string {
if (cadence.frequency === 'cron') {
return cadence.expression;
}
const { frequency, hour, minute } = cadence;
if (frequency === 'hourly') {
return `${minute} * * * *`;
}
if (frequency === 'daily') {
return `${minute} ${hour} * * *`;
}
if (frequency === 'weekdays') {
return `${minute} ${hour} * * 1-5`;
}
const days = cadence.daysOfWeek?.length ? cadence.daysOfWeek : [WEEKLY_DEFAULT_DAY];
return `${minute} ${hour} * * ${[...days].sort((a, b) => a - b).join(',')}`;
}
/**
* Everything `cronCadenceSchema` will accept: exactly five fields, within the length
* the schema stores, and actually matching at some point. croner accepts syntactically
* valid patterns that can never match (`0 0 30 2 *`), and those would arm a schedule
* that never fires. Validated with croner rather than a regex, because a regex would
* accept patterns croner then rejects at fire time.
*
* A five-field expression that matches at all matches forever, which is what lets every
* caller keep reading "no next occurrence" as "this cadence is unreadable".
*/
export function isValidCronExpression(expression: string, timezone?: string): boolean {
const trimmed = expression.trim();
if (trimmed.length > SCHEDULE_CRON_MAX_LENGTH) {
return false;
}
if (trimmed.split(/\s+/).length !== CRON_FIELD_COUNT) {
return false;
}
try {
return new Cron(trimmed, { timezone, paused: true }).nextRun() != null;
} catch {
return false;
}
}
/**
* The next occurrences the engine would fire. Server-side jitter (up to two
* minutes) is deliberately not modelled: it is keyed off a schedule id that does
* not exist yet at create time, and showing 9:01 for a 9:00 schedule reads as a bug.
*/
export function nextRunInstants(
cadence: TScheduleCadence,
timezone: string,
count: number,
): Date[] {
try {
const runs = new Cron(cadenceToCron(cadence), { timezone, paused: true }).nextRuns(count);
// At spring-forward croner folds the skipped wall-clock occurrences onto the
// first valid instant, so consecutive entries can repeat (`0 2,3 * * *` in a US
// zone yields 03:00 twice on the transition day). Those are one firing, which is
// also how the engine's unique-occurrence index counts them; previewing the same
// run twice reads as a bug.
return runs.filter((run, index) => index === 0 || run.getTime() !== runs[index - 1].getTime());
} catch {
return [];
}
}
const MINUTE_MS = 60_000;
const DAY_MS = 24 * 60 * MINUTE_MS;
/** A year and a bit, so a zone with a single yearly transition always shows one. */
const TRANSITION_SEARCH_DAYS = 400;
const offsetFormatters = new Map<string, Intl.DateTimeFormat>();
/** Minutes east of UTC in `zone` at `instant`, read back from the wall clock the
* zone renders. The only way to observe a zone's offset without a tz database. */
function zoneOffsetMinutes(zone: string, instant: Date): number {
let formatter = offsetFormatters.get(zone);
if (formatter == null) {
formatter = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
offsetFormatters.set(zone, formatter);
}
const parts: Record<string, string> = {};
for (const part of formatter.formatToParts(instant)) {
parts[part.type] = part.value;
}
const wallClock = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
);
return Math.round((wallClock - instant.getTime()) / MINUTE_MS);
}
/** Zone transitions are asked for once per fire, and the scan below costs ~10ms.
* Keyed by day because that is how far the answer stays put as the window slides,
* and cleared wholesale at the cap so a long-lived worker cannot accumulate an
* entry per zone per day forever. */
const transitionCache = new Map<string, Date[]>();
const TRANSITION_CACHE_MAX = 512;
/**
* Every instant `zone` changes its UTC offset within the search window; empty for a
* fixed-offset zone. Both directions matter and only one of them shortens anything,
* so taking just the next one would usually find the harmless fall-back and miss the
* spring-forward six months behind it. Bracketed a day at a time, bisected to the
* minute.
*/
function offsetChanges(zone: string, from: Date): Date[] {
// Scanned from the keyed DAY BOUNDARY, not from `from` itself. The key already
// discards everything below the day, so scanning from the exact instant let one
// caller cache a list that omits a transition earlier the same day, and the next
// caller that starts before it silently reused it and measured the nominal gap.
const day = Math.floor(from.getTime() / DAY_MS);
const cacheKey = `${zone}:${day}`;
const cached = transitionCache.get(cacheKey);
if (cached != null) {
return cached;
}
const changes: Date[] = [];
// Starts a day BEFORE the keyed one. Rounding forward to the anchor's own UTC day
// excluded a transition on the preceding UTC date, which is where a southern-
// hemisphere spring-forward lands: Australia/Sydney turns over at 16:00 UTC the day
// before the local date it belongs to, so the gap it compressed went unmeasured.
const start = new Date((day - 1) * DAY_MS);
let low = start.getTime();
let baseOffset = zoneOffsetMinutes(zone, start);
const end = low + TRANSITION_SEARCH_DAYS * DAY_MS;
for (let probe = low + DAY_MS; probe <= end; probe += DAY_MS) {
const offset = zoneOffsetMinutes(zone, new Date(probe));
if (offset !== baseOffset) {
let high = probe;
let bracket = low;
while (high - bracket > MINUTE_MS) {
const mid = bracket + Math.floor((high - bracket) / 2);
if (zoneOffsetMinutes(zone, new Date(mid)) === baseOffset) {
bracket = mid;
} else {
high = mid;
}
}
changes.push(new Date(high));
baseOffset = offset;
}
low = probe;
}
if (transitionCache.size >= TRANSITION_CACHE_MAX) {
transitionCache.clear();
}
transitionCache.set(cacheKey, changes);
return changes;
}
function runsAfter(
expression: string,
from: Date,
zone: string,
occurrences: number = CRON_PROBE_OCCURRENCES,
): Date[] {
return new Cron(expression, { timezone: zone, paused: true }).nextRuns(occurrences, from);
}
/** Tightest gap, in minutes, across `runs`; null when there is no pair to measure. */
function minGapMinutes(runs: Date[]): number | null {
if (runs.length < 2) {
return null;
}
let minGapMs = Number.MAX_SAFE_INTEGER;
for (let i = 1; i < runs.length; i++) {
const gapMs = runs[i].getTime() - runs[i - 1].getTime();
// At spring-forward croner folds the wall-clock hours that do not exist onto the
// one that replaced them, so the sequence can repeat an instant or step backwards
// (Antarctica/Troll skips two hours and reported a gap of -60). Those are one
// firing, which is also how the engine's unique-occurrence index counts them;
// treating them as gaps reported every hourly cron in a DST zone as unschedulable.
// Only non-positive gaps are dropped: a sub-minute gap is real and still floors to 0.
if (gapMs <= 0) {
continue;
}
minGapMs = Math.min(minGapMs, gapMs);
}
if (minGapMs === Number.MAX_SAFE_INTEGER) {
return null;
}
return Math.floor(minGapMs / MINUTE_MS);
}
function probeMinGapMinutes(
expression: string,
zone: string,
from: Date,
occurrences: number = CRON_PROBE_OCCURRENCES,
): number | null {
return minGapMinutes(runsAfter(expression, from, zone, occurrences));
}
/**
* Smallest gap in minutes between occurrences. Returns 0 for an unparseable or
* never-matching expression so every floor rejects it, failing closed rather than
* admitting an expression the engine cannot fire.
*
* Measured twice, and the smaller wins:
*
* 1. Nominal, probed in UTC, then discounted by the same worst-case DST allowance
* the structured branches assume. This keeps `0 9 * * *` reporting exactly what
* the Daily preset reports, so the same schedule cannot be admitted in one form
* and rejected in the other.
* 2. Real elapsed time in the schedule's own zone, across each of that zone's
* transitions. Spring-forward compresses a gap that straddles one
* (`0 0,12 * * *` in America/New_York is 11 hours that day, not 12), and step 1
* only discounts gaps of a day or more, so a subdaily gap needs measuring rather
* than estimating. Anchoring at the transition is what makes a bounded probe see
* it at all: from today it is usually months outside any reasonable window.
*/
function cronIntervalMinutes(expression: string, timezone?: string): number {
try {
const now = new Date();
const nominal = probeMinGapMinutes(expression, 'UTC', now);
if (nominal == null) {
// The expression can never match at all, so it must fail every floor.
return 0;
}
// Spring-forward can only compress a gap that spans the transition, so the
// blanket allowance applies from a day up. Taking it off an hourly gap too
// would reject `0 * * * *` against a 60-minute floor while Hourly passed.
const estimate = nominal < 24 * 60 ? nominal : Math.max(0, nominal - DST_COMPRESSION_MINUTES);
if (timezone == null || estimate === 0) {
return estimate;
}
// Sized off the nominal gap rather than fixed: starting two gaps before the
// transition and taking five occurrences spans at least four of them, so the
// straddling pair is always inside the window. A fixed one-day anchor with 32
// occurrences both overshot a sparse expression and, for a dense one, never
// reached the transition at all (32 minutes in, for `* * * * *`).
const anchorBackMs = 2 * Math.max(nominal, 1) * MINUTE_MS;
let smallest = estimate;
for (const transition of offsetChanges(timezone, now)) {
const measured = probeMinGapMinutes(
expression,
timezone,
// Clamped forward: the pair straddling a future transition still falls inside
// the window, while occurrences already behind the caller stay out of it.
new Date(Math.max(now.getTime(), transition.getTime() - anchorBackMs)),
TRANSITION_PROBE_OCCURRENCES,
);
if (measured != null) {
smallest = Math.min(smallest, measured);
}
}
return smallest;
} catch {
return 0;
}
}
/**
* Minimum minutes between occurrences, for the admin interval floor. `timezone` is
* the schedule's own; passing it lets the cron branch measure a DST-compressed gap
* instead of estimating one. The structured branches are zone-independent: their
* formulas already carry the worst-case allowance.
*/
export function cadenceIntervalMinutes(cadence: TScheduleCadence, timezone?: string): number {
if (isCronCadence(cadence)) {
return cronIntervalMinutes(cadence.expression, timezone);
}
if (cadence.frequency === 'hourly') {
return 60;
}
if (cadence.frequency === 'daily' || cadence.frequency === 'weekdays') {
return 24 * 60 - DST_COMPRESSION_MINUTES;
}
// Deduped defensively: the payload schema normalizes new writes, but a legacy
// stored [1, 1] would otherwise read as a zero-day gap and fail every floor.
const days = cadence.daysOfWeek?.length
? Array.from(new Set(cadence.daysOfWeek))
: [WEEKLY_DEFAULT_DAY];
if (days.length <= 1) {
return 7 * 24 * 60 - DST_COMPRESSION_MINUTES;
}
// The interval floor must reflect the SHORTEST gap between selected days
// (incl. the week wrap-around), e.g. [Mon, Tue] fires 24h apart, so it must be
// rejected against a >1440-minute floor.
const sorted = [...days].sort((a, b) => a - b);
let minGapDays = 7;
for (let i = 0; i < sorted.length; i++) {
const gap = i + 1 < sorted.length ? sorted[i + 1] - sorted[i] : 7 - sorted[i] + sorted[0];
minGapDays = Math.min(minGapDays, gap);
}
return minGapDays * 24 * 60 - DST_COMPRESSION_MINUTES;
}

View file

@ -30,6 +30,7 @@ export * from './types/mcpServers';
export * from './types/mutations';
export * from './types/queries';
export * from './types/schedules';
export * from './cadence';
export * from './types/skills';
export * from './types/runs';
export * from './types/web';

View file

@ -1,8 +1,16 @@
import { z } from 'zod';
export const scheduleFrequencies = ['hourly', 'daily', 'weekdays', 'weekly'] as const;
/** Cadences the dialog builds from structured pickers (hour, minute, weekday). */
export const scheduleStructuredFrequencies = ['hourly', 'daily', 'weekdays', 'weekly'] as const;
export type ScheduleStructuredFrequency = (typeof scheduleStructuredFrequencies)[number];
export const scheduleFrequencies = [...scheduleStructuredFrequencies, 'cron'] as const;
export type ScheduleFrequency = (typeof scheduleFrequencies)[number];
/** Bounds a stored expression. Generous for five fields, because each one can hold a
* list: an every-minute-of-the-hour cadence spelled out runs past two hundred chars. */
export const SCHEDULE_CRON_MAX_LENGTH = 256;
export const scheduleTargets = ['new'] as const;
export type ScheduleTarget = (typeof scheduleTargets)[number];
@ -24,8 +32,8 @@ export type ScheduleRunStatus =
| 'skipped_overlap'
| 'skipped_balance';
export const scheduleCadenceSchema = z.object({
frequency: z.enum(scheduleFrequencies),
export const structuredCadenceSchema = z.object({
frequency: z.enum(scheduleStructuredFrequencies),
hour: z.number().int().min(0).max(23),
minute: z.number().int().min(0).max(59),
daysOfWeek: z
@ -35,8 +43,29 @@ export const scheduleCadenceSchema = z.object({
.transform((days) => Array.from(new Set(days)))
.optional(),
});
export type TStructuredCadence = z.infer<typeof structuredCadenceSchema>;
/**
* A raw cron expression carries its own hour and minute, so it cannot share the
* structured shape: there is no single `hour` for `0 9,17 * * 1-5`. Syntax is
* validated server-side by croner, the same parser the engine fires from, rather
* than by a regex that would accept patterns croner then rejects at fire time.
*/
export const cronCadenceSchema = z.object({
frequency: z.literal('cron'),
expression: z.string().trim().min(1).max(SCHEDULE_CRON_MAX_LENGTH),
});
export type TCronCadence = z.infer<typeof cronCadenceSchema>;
export const scheduleCadenceSchema = z.discriminatedUnion('frequency', [
structuredCadenceSchema,
cronCadenceSchema,
]);
export type TScheduleCadence = z.infer<typeof scheduleCadenceSchema>;
export const isCronCadence = (cadence: TScheduleCadence): cadence is TCronCadence =>
cadence.frequency === 'cron';
export const createSchedulePayloadSchema = z.object({
name: z.string().trim().min(1).max(256),
prompt: z.string().trim().min(1).max(32000),
@ -129,6 +158,9 @@ export type TScheduleRun = {
* path enforce, so the form can never offer a choice the server would refuse. */
export type TScheduleLimits = {
maxPerUser: number;
/** Served with the list so the dialog can refuse a cadence the floor would reject
* rather than surfacing it as a 400 after submit. */
minIntervalMinutes: number;
/** Every schedule must be filed under a chat project. */
requireProject: boolean;
/** Operator-pinned destination project; when set it is the ONLY destination and

View file

@ -2845,4 +2845,29 @@ describe('erasure sweep rotation and idempotency-key lookup', () => {
const found = await methods.getScheduleByClientRequestId(user, 'intent-9');
expect(found?.id).toBe(schedule.id);
});
it('persists a cron cadence without an hour or a minute', async () => {
const created = await Schedule.create(
scheduleData({ cadence: { frequency: 'cron', expression: '0 9,17 * * 1-5' } }),
);
const stored = await getSchedule(created.id);
expect(stored.cadence).toMatchObject({ frequency: 'cron', expression: '0 9,17 * * 1-5' });
expect('hour' in stored.cadence).toBe(false);
expect('minute' in stored.cadence).toBe(false);
});
it('refuses a cron cadence with no expression', async () => {
// Without this the row is armed but uncompilable: the engine reads a null next
// run as an unreadable cadence and disables it, silently, after the save.
await expect(
Schedule.create(scheduleData({ cadence: { frequency: 'cron' } as ISchedule['cadence'] })),
).rejects.toThrow(/expression/);
});
it('still requires an hour and a minute for a structured cadence', async () => {
await expect(
Schedule.create(scheduleData({ cadence: { frequency: 'daily' } as ISchedule['cadence'] })),
).rejects.toThrow(/hour/);
});
});

View file

@ -1,6 +1,19 @@
import { Schema } from 'mongoose';
import { SCHEDULE_CRON_MAX_LENGTH } from 'librechat-data-provider';
import type { IScheduleDocument } from '~/types/schedule';
/** `cadence` is a nested path rather than a subdocument, so a validator on
* `cadence.hour` is called with the DOCUMENT as `this`, not the cadence object.
* Reading `this.frequency` there is always undefined, which made every cadence
* look like a structured one and rejected every cron write. */
interface CadenceValidatorDocument {
cadence?: { frequency?: string };
}
function isStructuredCadence(this: CadenceValidatorDocument): boolean {
return this.cadence?.frequency !== 'cron';
}
const scheduleSchema: Schema<IScheduleDocument> = new Schema(
{
id: {
@ -35,12 +48,40 @@ const scheduleSchema: Schema<IScheduleDocument> = new Schema(
cadence: {
frequency: {
type: String,
enum: ['hourly', 'daily', 'weekdays', 'weekly'],
enum: ['hourly', 'daily', 'weekdays', 'weekly', 'cron'],
required: true,
},
hour: { type: Number, min: 0, max: 23, required: true },
minute: { type: Number, min: 0, max: 59, required: true },
/**
* Required for the structured cadences only. A cron row carries its hour and
* minute inside `expression`, so a blanket `required: true` here would reject
* every cron write; the function form keeps the guarantee for the four
* structured frequencies, where a missing hour would silently fire at 00:00.
*/
hour: {
type: Number,
min: 0,
max: 23,
required: isStructuredCadence,
},
minute: {
type: Number,
min: 0,
max: 59,
required: isStructuredCadence,
},
daysOfWeek: { type: [Number], default: undefined },
/** The mirror of the rule above: a cron row IS its expression, so persisting
* one without it arms a schedule the engine cannot compile a next run from. */
expression: {
type: String,
default: undefined,
/** Mirrors the zod gate, so a methods-layer writer bypassing the handlers
* cannot persist an expression the engine then refuses to compile. */
maxlength: SCHEDULE_CRON_MAX_LENGTH,
required: function (this: CadenceValidatorDocument) {
return !isStructuredCadence.call(this);
},
},
},
timezone: {
type: String,