* 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.
130 lines
3.6 KiB
TypeScript
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";
|
|
}
|
|
}
|