Files
paseo/packages/protocol/src/schedule/cron-expression.ts
T
Mohamed BoudraandGitHub bf5e3b47e7 Add a Schedules screen to manage recurring agents (#1246)
* feat: add Schedules screen to manage scheduled agents

Adds a Schedules section in the sidebar header (below Sessions) for viewing
and managing new-agent schedules: recurring jobs that spawn an agent on a
cron or interval cadence.

- Borderless table showing provider icon, model, cadence, next run, and
  status, with row actions to edit, pause/resume, run now, and delete.
- Create/edit sheet that reuses the agent provider/model/mode/working-
  directory pickers, plus an interval/cron cadence editor with presets,
  validation, and a UTC preview.
- Responsive: hover-revealed row actions on desktop, an always-visible
  action menu on mobile.
- Data layer over the existing schedule RPCs (list/create/update/pause/
  resume/delete/run-once) with optimistic pause, resume, and delete.

Scope is limited to new-agent schedules for now.

* Make the schedule form controls visually consistent

Merge the provider and model pickers into the single nested selector the
draft screen uses, render it as a full-width field, mute the placeholder,
proportion the cadence segmented controls, unify the cron preset chips,
and route the sheet scroll through the themed scrollbar.

* Address schedules review feedback

* Translate schedule hook client fallback errors

* Make schedules global and project scoped

* Stabilize project settings transport E2E

* Reset schedule model on cross-host project change

* Reject malformed cron step expressions

* Tighten schedules project query freshness

* refactor(schedules): clean up review feedback

* test(schedules): extract fake host setup

* refactor(schedules): share cron expression parsing

Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces.

Remove the unused host-scoped schedules redirect while keeping the global schedules route.
2026-06-30 22:58:49 +02:00

130 lines
3.6 KiB
TypeScript

interface CronFieldBounds {
min: number;
max: number;
name: string;
}
export interface CronFieldMatcher {
matches(value: number): boolean;
}
export interface ParsedCronExpression {
minute: CronFieldMatcher;
hour: CronFieldMatcher;
dayOfMonth: CronFieldMatcher;
month: CronFieldMatcher;
dayOfWeek: CronFieldMatcher;
}
const CRON_FIELD_BOUNDS: CronFieldBounds[] = [
{ min: 0, max: 59, name: "minute" },
{ min: 0, max: 23, name: "hour" },
{ min: 1, max: 31, name: "day-of-month" },
{ min: 1, max: 12, name: "month" },
{ min: 0, max: 6, name: "day-of-week" },
];
function createRange(start: number, end: number, step: number): number[] {
const values: number[] = [];
for (let value = start; value <= end; value += step) {
values.push(value);
}
return values;
}
function parseStepPart(part: string, bounds: CronFieldBounds): { base: string; step: number } {
const stepParts = part.split("/");
if (stepParts.length > 2) {
throw new Error(`Invalid cron ${bounds.name} step`);
}
const [base, stepSource] = stepParts;
const normalizedStep = stepSource?.trim();
const step = normalizedStep === undefined ? 1 : Number.parseInt(normalizedStep, 10);
if (
!Number.isInteger(step) ||
step <= 0 ||
(normalizedStep !== undefined && String(step) !== normalizedStep)
) {
throw new Error(`Invalid cron ${bounds.name} step`);
}
return { base, step };
}
function parseField(source: string, bounds: CronFieldBounds): CronFieldMatcher {
const trimmed = source.trim();
if (!trimmed) {
throw new Error(`Invalid cron ${bounds.name} field`);
}
const allowed = new Set<number>();
for (const rawPart of trimmed.split(",")) {
const part = rawPart.trim();
if (!part) {
throw new Error(`Invalid cron ${bounds.name} field`);
}
const { base, step } = parseStepPart(part, bounds);
if (base === "*") {
for (const value of createRange(bounds.min, bounds.max, step)) {
allowed.add(value);
}
continue;
}
const rangeMatch = base.match(/^(\d+)-(\d+)$/);
if (rangeMatch) {
const start = Number.parseInt(rangeMatch[1], 10);
const end = Number.parseInt(rangeMatch[2], 10);
if (start > end || start < bounds.min || end > bounds.max) {
throw new Error(`Invalid cron ${bounds.name} range`);
}
for (const value of createRange(start, end, step)) {
allowed.add(value);
}
continue;
}
if (!/^\d+$/.test(base)) {
throw new Error(`Invalid cron ${bounds.name} value`);
}
const value = Number.parseInt(base, 10);
if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) {
throw new Error(`Invalid cron ${bounds.name} value`);
}
allowed.add(value);
}
return {
matches(value: number): boolean {
return allowed.has(value);
},
};
}
export function parseCronExpression(expression: string): ParsedCronExpression {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error("Cron expressions must have 5 fields");
}
return {
minute: parseField(parts[0], CRON_FIELD_BOUNDS[0]),
hour: parseField(parts[1], CRON_FIELD_BOUNDS[1]),
dayOfMonth: parseField(parts[2], CRON_FIELD_BOUNDS[2]),
month: parseField(parts[3], CRON_FIELD_BOUNDS[3]),
dayOfWeek: parseField(parts[4], CRON_FIELD_BOUNDS[4]),
};
}
export function validateCronExpression(expression: string): string | null {
try {
parseCronExpression(expression);
return null;
} catch (error) {
return error instanceof Error ? error.message : "Invalid cron expression";
}
}