Files
Triple-C/app/src/components/projects/home/taskValidation.ts
T

330 lines
13 KiB
TypeScript
Raw Normal View History

/**
* 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<number>();
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}.`;
}