/** * Client-side mirror of the scheduled-task rules in * `src-tauri/src/commands/inspect_commands.rs`, plus a plain-English reading of * a cron expression. * * The backend remains the authority — it re-validates everything and is the * only thing standing between a prompt and the container. This module exists so * the form can say what is wrong *before* a round trip, and so the cron field * can show the user what they actually typed. * * The cron rules match Debian/vixie cron, which is what the container runs: * five fields, names in month and day-of-week only, day-of-week 0–7, and a * `/step` only after `*` or a range (vixie rejects `1/2`). */ export const MAX_TASK_NAME_LEN = 100; export const MAX_TASK_PROMPT_LEN = 8000; export const MAX_WORKING_DIR_LEN = 512; export const DEFAULT_WORKING_DIR = "/workspace"; const MAX_CRON_LEN = 256; const MAX_CRON_STEP = 1000; const MONTH_NAMES = [ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", ]; const DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; const MONTH_LABELS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const DOW_LABELS = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", ]; interface CronFieldSpec { label: string; min: number; max: number; names: string[]; /** Numeric value of `names[0]` — 1 for January, 0 for Sunday. */ nameBase: number; } const CRON_FIELDS: CronFieldSpec[] = [ { label: "minute", min: 0, max: 59, names: [], nameBase: 0 }, { label: "hour", min: 0, max: 23, names: [], nameBase: 0 }, { label: "day of month", min: 1, max: 31, names: [], nameBase: 0 }, { label: "month", min: 1, max: 12, names: MONTH_NAMES, nameBase: 1 }, { label: "day of week", min: 0, max: 7, names: DOW_NAMES, nameBase: 0 }, ]; /** A handful of schedules that cover most of what people actually want. */ export const CRON_PRESETS: { label: string; expression: string }[] = [ { label: "Every 30 minutes", expression: "*/30 * * * *" }, { label: "Hourly", expression: "0 * * * *" }, { label: "Daily at 09:00", expression: "0 9 * * *" }, { label: "Weekdays at 09:00", expression: "0 9 * * 1-5" }, { label: "Mondays at 08:00", expression: "0 8 * * 1" }, ]; // ── Field validation ───────────────────────────────────────────────────────── /** `null` means valid; otherwise the message to show under the field. */ export type FieldError = string | null; /** C0 and C1 control characters. */ // eslint-disable-next-line no-control-regex const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/; /** The same, minus tab / LF / CR — a multi-line prompt is normal. */ // eslint-disable-next-line no-control-regex const CONTROL_CHARS_EXCEPT_WHITESPACE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/; const hasControlChars = (value: string, allowNewlines: boolean) => (allowNewlines ? CONTROL_CHARS_EXCEPT_WHITESPACE : CONTROL_CHARS).test(value); export function validateTaskName(name: string): FieldError { const trimmed = name.trim(); if (!trimmed) return "Task name is required."; if ([...trimmed].length > MAX_TASK_NAME_LEN) return `Task name is too long (max ${MAX_TASK_NAME_LEN} characters).`; if (hasControlChars(trimmed, false)) return "Task name must be a single line."; if (trimmed.startsWith("-")) return "Task name cannot start with “-”."; return null; } export function validateTaskPrompt(prompt: string): FieldError { const trimmed = prompt.trim(); if (!trimmed) return "Task prompt is required."; if ([...trimmed].length > MAX_TASK_PROMPT_LEN) return `Task prompt is too long (max ${MAX_TASK_PROMPT_LEN} characters).`; if (hasControlChars(trimmed, true)) return "Task prompt contains an unsupported character."; return null; } export function validateWorkingDir(dir: string): FieldError { const trimmed = dir.trim(); if (!trimmed) return null; // Blank falls back to /workspace, as the CLI does. if ([...trimmed].length > MAX_WORKING_DIR_LEN) return `Working directory is too long (max ${MAX_WORKING_DIR_LEN} characters).`; if (hasControlChars(trimmed, false)) return "Working directory must be a single line."; if (!trimmed.startsWith("/")) return "Working directory must be an absolute path inside the container, e.g. /workspace."; if (trimmed.split("/").includes("..")) return "Working directory cannot contain “..”."; return null; } // ── Cron ───────────────────────────────────────────────────────────────────── function cronValue(spec: CronFieldSpec, token: string): number | null { if (token.length > 0 && /^[0-9]+$/.test(token)) { const value = Number(token); return value >= spec.min && value <= spec.max ? value : null; } const index = spec.names.indexOf(token.toLowerCase()); return index >= 0 ? index + spec.nameBase : null; } function validateCronElement(spec: CronFieldSpec, element: string): FieldError { if (!element) return `Empty value in the ${spec.label} field.`; const slash = element.indexOf("/"); const base = slash === -1 ? element : element.slice(0, slash); if (slash !== -1) { const raw = element.slice(slash + 1); if (!/^[0-9]{1,4}$/.test(raw)) return `“${element}” in the ${spec.label} field: a step must be a number, like */5.`; const step = Number(raw); if (step < 1 || step > MAX_CRON_STEP) return `“${element}” in the ${spec.label} field: a step must be between 1 and ${MAX_CRON_STEP}.`; if (base !== "*" && !base.includes("-")) return `“${element}” in the ${spec.label} field: a step can only follow * or a range, like */5 or 1-5/2.`; } if (base === "*") return null; const dash = base.indexOf("-"); const tokens = dash === -1 ? [base] : [base.slice(0, dash), base.slice(dash + 1)]; for (const token of tokens) { if (cronValue(spec, token) === null) { return /^[0-9]+$/.test(token) ? `“${token}” is out of range for the ${spec.label} field (${spec.min}–${spec.max}).` : `“${token}” is not valid in the ${spec.label} field.`; } } return null; } export function validateCronExpression(expression: string): FieldError { if (expression.length > MAX_CRON_LEN) return `Cron expression is too long (max ${MAX_CRON_LEN} characters).`; const fields = expression.trim().split(/\s+/).filter(Boolean); if (fields.length !== 5) return `A cron schedule needs exactly 5 fields (minute hour day-of-month month day-of-week); got ${fields.length}.`; for (let i = 0; i < 5; i++) { for (const element of fields[i].split(",")) { const error = validateCronElement(CRON_FIELDS[i], element); if (error) return error; } } return null; } /** Matches the scheduler's own `--at` regex, then checks it is a real instant. */ export function validateAtTimestamp(at: string): FieldError { const trimmed = at.trim(); if (!trimmed) return "A date and time is required."; const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(trimmed); if (!match) return "Use the format YYYY-MM-DD HH:MM, e.g. 2026-12-25 09:05."; const [, y, mo, d, h, mi] = match.map(Number); const date = new Date(y, mo - 1, d, h, mi); const real = date.getFullYear() === y && date.getMonth() === mo - 1 && date.getDate() === d && date.getHours() === h && date.getMinutes() === mi; return real ? null : "That is not a real date and time."; } /** `true` when a valid one-shot time has already passed (a warning, not an error). */ export function atTimestampIsPast(at: string, now: Date = new Date()): boolean { if (validateAtTimestamp(at)) return false; const [datePart, timePart] = at.trim().split(" "); const [y, mo, d] = datePart.split("-").map(Number); const [h, mi] = timePart.split(":").map(Number); return new Date(y, mo - 1, d, h, mi).getTime() < now.getTime(); } // ── Plain-English reading of a cron expression ─────────────────────────────── const pad = (n: number) => String(n).padStart(2, "0"); function joinList(items: string[]): string { if (items.length <= 1) return items[0] ?? ""; if (items.length === 2) return `${items[0]} and ${items[1]}`; return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; } /** The step of a bare `*​/n` field, or `null` for anything else. */ function simpleStep(field: string): number | null { const match = /^\*\/([0-9]+)$/.exec(field); return match ? Number(match[1]) : null; } /** * Every value a (already valid) field selects, or `null` for "all of them". * Bounded by the field's own range, so this cannot run away. */ function expandField(spec: CronFieldSpec, field: string): number[] | null { if (field === "*") return null; const values = new Set(); for (const element of field.split(",")) { const slash = element.indexOf("/"); const base = slash === -1 ? element : element.slice(0, slash); const step = slash === -1 ? 1 : Number(element.slice(slash + 1)); let from: number; let to: number; if (base === "*") { from = spec.min; to = spec.max; } else { const dash = base.indexOf("-"); if (dash === -1) { from = to = cronValue(spec, base) as number; } else { from = cronValue(spec, base.slice(0, dash)) as number; to = cronValue(spec, base.slice(dash + 1)) as number; } } for (let v = from; v <= to; v += step) values.add(v); } const sorted = [...values].sort((a, b) => a - b); // A field that names every value reads better as "every". return sorted.length >= spec.max - spec.min + 1 ? null : sorted; } const isContiguous = (values: number[]) => values.every((v, i) => i === 0 || v === values[i - 1] + 1); function timePhrase( minutes: number[] | null, hours: number[] | null, minuteField: string, hourField: string, ): string { if (minutes === null && hours === null) return "Every minute"; if (hours === null) { const step = simpleStep(minuteField); if (step !== null) return step === 1 ? "Every minute" : `Every ${step} minutes`; return `At ${joinList((minutes as number[]).map((m) => `:${pad(m)}`))} past every hour`; } if (minutes === null) { return `Every minute of ${joinList(hours.map((h) => `${pad(h)}:00`))}`; } const hourStep = simpleStep(hourField); if (hourStep !== null && minutes.length === 1) { return `At :${pad(minutes[0])} past every ${hourStep === 1 ? "hour" : `${hourStep} hours`}`; } if (minutes.length === 1 && hours.length >= 3 && isContiguous(hours)) { return `At :${pad(minutes[0])} past every hour from ${pad(hours[0])}:00 to ${pad( hours[hours.length - 1], )}:00`; } const times: string[] = []; for (const h of hours) for (const m of minutes) times.push(`${pad(h)}:${pad(m)}`); if (times.length <= 6) return `At ${joinList(times)}`; return `At minute ${joinList(minutes.map(String))} of hour ${joinList(hours.map(String))}`; } function weekdayPhrase(dows: number[]): string { const labels = dows.map((d) => DOW_LABELS[d]); if (dows.length >= 3 && isContiguous(dows)) return `${labels[0]} to ${labels[labels.length - 1]}`; return joinList(labels); } function dayPhrase(doms: number[] | null, dows: number[] | null): string { if (doms === null && dows === null) return "every day"; if (dows !== null && doms === null) return `on ${weekdayPhrase(dows)}`; if (doms !== null && dows === null) return `on day ${joinList(doms.map(String))} of the month`; // Cron ORs the two day fields when both are restricted. return `on day ${joinList((doms as number[]).map(String))} of the month or on ${weekdayPhrase( dows as number[], )}`; } /** * Read a cron expression back to the user in English, or `null` if it is not * valid. Deliberately a *reading*, not a scheduler: it never claims to know the * next run time. */ export function describeCron(expression: string): string | null { if (validateCronExpression(expression)) return null; const [minuteField, hourField, domField, monthField, dowField] = expression .trim() .split(/\s+/); const minutes = expandField(CRON_FIELDS[0], minuteField); const hours = expandField(CRON_FIELDS[1], hourField); const doms = expandField(CRON_FIELDS[2], domField); const months = expandField(CRON_FIELDS[3], monthField); let dows = expandField(CRON_FIELDS[4], dowField); if (dows) { // 0 and 7 are both Sunday. dows = [...new Set(dows.map((d) => (d === 7 ? 0 : d)))].sort((a, b) => a - b); if (dows.length === 7) dows = null; } const monthPart = months === null ? "" : ` in ${joinList(months.map((m) => MONTH_LABELS[m - 1]))}`; return `${timePhrase(minutes, hours, minuteField, hourField)}, ${dayPhrase( doms, dows, )}${monthPart}.`; }