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.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCronExpression, validateCronExpression } from "./cron-expression.js";
|
||||
|
||||
describe("cron expression parser", () => {
|
||||
it("matches structurally valid expressions", () => {
|
||||
const cron = parseCronExpression("*/5 9-17 * 1,6 1-5");
|
||||
|
||||
expect(cron.minute.matches(10)).toBe(true);
|
||||
expect(cron.minute.matches(11)).toBe(false);
|
||||
expect(cron.hour.matches(12)).toBe(true);
|
||||
expect(cron.hour.matches(18)).toBe(false);
|
||||
expect(cron.month.matches(6)).toBe(true);
|
||||
expect(cron.dayOfWeek.matches(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports invalid expressions with server-facing copy", () => {
|
||||
expect(validateCronExpression("* * *")).toBe("Cron expressions must have 5 fields");
|
||||
expect(validateCronExpression("*/5/2 * * * *")).toBe("Invalid cron minute step");
|
||||
expect(validateCronExpression("60 * * * *")).toBe("Invalid cron minute value");
|
||||
expect(validateCronExpression("* 24 * * *")).toBe("Invalid cron hour value");
|
||||
expect(validateCronExpression("* * 31-1 * *")).toBe("Invalid cron day-of-month range");
|
||||
expect(validateCronExpression("* * * */0 *")).toBe("Invalid cron month step");
|
||||
expect(validateCronExpression("* * * * mon")).toBe("Invalid cron day-of-week value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user