Download src/utils/automation-schedule.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 7.64 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/utils/automation-schedule.ts
- Command line
-
hf download hf://SaylorTwift/openhands/src/utils/automation-schedule.ts
-
curl -L -o automation-schedule.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/src/utils/automation-schedule.ts
7.64 kB
| import { I18nKey } from "#/i18n/declaration"; | |
| export type SchedulePresetKind = "daily" | "weekdays" | "weekly"; | |
| export interface PresetSchedule { | |
| kind: SchedulePresetKind; | |
| hour: number; | |
| minute: number; | |
| weekday?: number; | |
| } | |
| export interface CustomSchedule { | |
| kind: "custom"; | |
| raw: string; | |
| hour?: number; | |
| minute?: number; | |
| } | |
| export type ParsedSchedule = PresetSchedule | CustomSchedule; | |
| const SINGLE_INT = /^(\d+)$/; | |
| function parseSingleInt( | |
| field: string, | |
| min: number, | |
| max: number, | |
| ): number | null { | |
| const match = field.match(SINGLE_INT); | |
| if (!match) return null; | |
| const value = Number(match[1]); | |
| if (Number.isNaN(value) || value < min || value > max) return null; | |
| return value; | |
| } | |
| export function parseCronSchedule( | |
| cron: string | undefined | null, | |
| ): ParsedSchedule { | |
| const raw = (cron ?? "").trim(); | |
| if (!raw) return { kind: "custom", raw: "" }; | |
| const fields = raw.split(/\s+/); | |
| if (fields.length !== 5) return { kind: "custom", raw }; | |
| const [minuteField, hourField, domField, monthField, dowField] = fields; | |
| const minute = parseSingleInt(minuteField, 0, 59); | |
| const hour = parseSingleInt(hourField, 0, 23); | |
| if (minute === null || hour === null) { | |
| return { kind: "custom", raw }; | |
| } | |
| if (domField !== "*" || monthField !== "*") { | |
| return { kind: "custom", raw, hour, minute }; | |
| } | |
| if (dowField === "*" || dowField === "0-6") { | |
| return { kind: "daily", hour, minute }; | |
| } | |
| if (dowField === "1-5") { | |
| return { kind: "weekdays", hour, minute }; | |
| } | |
| const weekday = parseSingleInt(dowField, 0, 6); | |
| if (weekday !== null) { | |
| return { kind: "weekly", hour, minute, weekday }; | |
| } | |
| return { kind: "custom", raw, hour, minute }; | |
| } | |
| export function buildCronSchedule(input: PresetSchedule): string { | |
| const { kind, hour, minute, weekday } = input; | |
| switch (kind) { | |
| case "daily": | |
| return `${minute} ${hour} * * *`; | |
| case "weekdays": | |
| return `${minute} ${hour} * * 1-5`; | |
| case "weekly": | |
| return `${minute} ${hour} * * ${weekday ?? 1}`; | |
| default: { | |
| const _exhaustive: never = kind; | |
| return _exhaustive; | |
| } | |
| } | |
| } | |
| export function formatTimeOfDay(hour: number, minute: number): string { | |
| const hh = String(hour).padStart(2, "0"); | |
| const mm = String(minute).padStart(2, "0"); | |
| return `${hh}:${mm}`; | |
| } | |
| export function parseTimeOfDay( | |
| value: string, | |
| ): { hour: number; minute: number } | null { | |
| const match = value.match(/^(\d{1,2}):(\d{2})$/); | |
| if (!match) return null; | |
| const hour = Number(match[1]); | |
| const minute = Number(match[2]); | |
| if ( | |
| Number.isNaN(hour) || | |
| Number.isNaN(minute) || | |
| hour < 0 || | |
| hour > 23 || | |
| minute < 0 || | |
| minute > 59 | |
| ) { | |
| return null; | |
| } | |
| return { hour, minute }; | |
| } | |
| export function formatEventOn(on: string | string[] | undefined): string { | |
| if (!on) return "—"; | |
| if (Array.isArray(on)) return on.join(", "); | |
| return on; | |
| } | |
| /** Example expression shown when the cron field is empty. */ | |
| export const CRON_EXPRESSION_EXAMPLE = "*/10 * * * *"; | |
| // The service validates with croniter, so this must accept everything croniter | |
| // does: five fields, optionally plus seconds and then year, or an alias. | |
| const CRON_ALIASES = new Set([ | |
| "@yearly", | |
| "@annually", | |
| "@monthly", | |
| "@weekly", | |
| "@daily", | |
| "@midnight", | |
| "@hourly", | |
| ]); | |
| const CRON_MIN_FIELDS = 5; | |
| const CRON_MAX_FIELDS = 7; | |
| // Seconds and year are appended after these, so the positions always hold. | |
| const CRON_FIELD_BOUNDS: readonly (readonly [number, number])[] = [ | |
| [0, 59], // minute | |
| [0, 23], // hour | |
| [1, 31], // day of month | |
| [1, 12], // month | |
| [0, 7], // day of week — croniter spells Sunday 0 and 7 | |
| ]; | |
| const DAY_OF_MONTH_INDEX = 2; | |
| const MONTH_INDEX = 3; | |
| // Indexed from 1. February is 29: the 29th does fire, in leap years. | |
| const MAX_DAYS_IN_MONTH = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; | |
| const MONTH_NAMES = "JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC"; | |
| const DAY_NAMES = "SUN|MON|TUE|WED|THU|FRI|SAT"; | |
| // croniter does not bound the step: `*/90` is accepted. | |
| const NUMERIC_TERM = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/; | |
| /** Vocabulary beyond plain numbers, matched but not expanded. */ | |
| const EXTRA_TERM_PATTERNS: readonly (RegExp | null)[] = [ | |
| null, // minute | |
| null, // hour | |
| /^(?:\?|L|\d+W)$/i, // `?`, `L`, `15W` | |
| new RegExp(`^(?:${MONTH_NAMES})(?:-(?:${MONTH_NAMES}))?(?:/\\d+)?$`, "i"), | |
| // `SUN`, `MON-FRI#2`, `5#3`, `?` — but no `L`, which croniter rejects here. | |
| new RegExp( | |
| `^(?:(?:${DAY_NAMES}|\\d+)(?:-(?:${DAY_NAMES}|\\d+))?(?:/\\d+)?(?:#\\d+)?|\\?)$`, | |
| "i", | |
| ), | |
| ]; | |
| type CronFieldParse = | |
| | { kind: "invalid" } | |
| /** Well-formed, but not expanded, so it constrains nothing. */ | |
| | { kind: "unmodelled" } | |
| | { kind: "values"; values: number[] }; | |
| function parseCronField( | |
| field: string, | |
| [min, max]: readonly [number, number], | |
| extraPattern: RegExp | null, | |
| ): CronFieldParse { | |
| const values = new Set<number>(); | |
| for (const term of field.split(",")) { | |
| const match = term.match(NUMERIC_TERM); | |
| if (!match) { | |
| if (extraPattern?.test(term)) return { kind: "unmodelled" }; | |
| return { kind: "invalid" }; | |
| } | |
| const [, rangePart, stepPart] = match; | |
| const step = stepPart === undefined ? 1 : Number(stepPart); | |
| if (step < 1) return { kind: "invalid" }; | |
| if (rangePart === "*") { | |
| for (let value = min; value <= max; value += step) values.add(value); | |
| continue; | |
| } | |
| const [startPart, endPart] = rangePart.split("-"); | |
| const start = parseSingleInt(startPart, min, max); | |
| if (start === null) return { kind: "invalid" }; | |
| if (endPart === undefined) { | |
| values.add(start); | |
| continue; | |
| } | |
| const end = parseSingleInt(endPart, min, max); | |
| if (end === null) return { kind: "invalid" }; | |
| // croniter wraps a reversed range like `5-1`; that wrap is not modelled. | |
| if (end < start) return { kind: "unmodelled" }; | |
| for (let value = start; value <= end; value += step) values.add(value); | |
| } | |
| return { kind: "values", values: [...values] }; | |
| } | |
| export type CronScheduleValidation = | |
| | { schedule: string } | |
| | { errorKey: I18nKey }; | |
| /** | |
| * Validate a raw cron expression from the edit form — `parseCronSchedule` only | |
| * classifies against the presets, reporting arbitrary text as `custom`. | |
| * | |
| * One-sided by design: rejects only what croniter certainly rejects, and defers | |
| * on anything it does not model. | |
| */ | |
| export function validateCronSchedule(raw: string): CronScheduleValidation { | |
| const schedule = raw.trim(); | |
| if (!schedule) return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID }; | |
| if (schedule.startsWith("@")) { | |
| return CRON_ALIASES.has(schedule.toLowerCase()) | |
| ? { schedule } | |
| : { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID }; | |
| } | |
| const fields = schedule.split(/\s+/); | |
| if (fields.length < CRON_MIN_FIELDS || fields.length > CRON_MAX_FIELDS) { | |
| return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID }; | |
| } | |
| const parsed = CRON_FIELD_BOUNDS.map((bounds, index) => | |
| parseCronField(fields[index], bounds, EXTRA_TERM_PATTERNS[index]), | |
| ); | |
| if (parsed.some((field) => field.kind === "invalid")) { | |
| return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID }; | |
| } | |
| // croniter parses `0 0 31 2 *` and then finds no fire time for it. | |
| const days = parsed[DAY_OF_MONTH_INDEX]; | |
| const months = parsed[MONTH_INDEX]; | |
| if ( | |
| days.kind === "values" && | |
| months.kind === "values" && | |
| !months.values.some((month) => | |
| days.values.some((day) => day <= MAX_DAYS_IN_MONTH[month]), | |
| ) | |
| ) { | |
| return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_UNREACHABLE }; | |
| } | |
| return { schedule }; | |
| } | |