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:
@@ -54,6 +54,12 @@ describe("schedule cron cadence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects step fields with extra slash tokens", () => {
|
||||
expect(() => validateScheduleCadence({ type: "cron", expression: "*/5/2 * * * *" })).toThrow(
|
||||
"Invalid cron minute step",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid cron time zones", () => {
|
||||
expect(() =>
|
||||
validateScheduleCadence({
|
||||
|
||||
@@ -1,86 +1,6 @@
|
||||
import { parseCronExpression } from "@getpaseo/protocol/schedule/cron-expression";
|
||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
interface CronFieldMatcher {
|
||||
matches(value: number): boolean;
|
||||
}
|
||||
|
||||
function buildValueSet(values: Iterable<number>): Set<number> {
|
||||
return new Set(values);
|
||||
}
|
||||
|
||||
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 parseField(
|
||||
source: string,
|
||||
bounds: { min: number; max: number; name: string },
|
||||
): CronFieldMatcher {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Invalid cron ${bounds.name} field`);
|
||||
}
|
||||
|
||||
const values = 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, stepSource] = part.split("/");
|
||||
const step = stepSource === undefined ? 1 : Number.parseInt(stepSource, 10);
|
||||
if (!Number.isInteger(step) || step <= 0) {
|
||||
throw new Error(`Invalid cron ${bounds.name} step`);
|
||||
}
|
||||
|
||||
if (base === "*") {
|
||||
for (const value of createRange(bounds.min, bounds.max, step)) {
|
||||
values.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)) {
|
||||
values.add(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = Number.parseInt(base, 10);
|
||||
if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) {
|
||||
throw new Error(`Invalid cron ${bounds.name} value`);
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
|
||||
const allowed = buildValueSet(values);
|
||||
return {
|
||||
matches(value: number): boolean {
|
||||
return allowed.has(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedCronExpression {
|
||||
minute: CronFieldMatcher;
|
||||
hour: CronFieldMatcher;
|
||||
dayOfMonth: CronFieldMatcher;
|
||||
month: CronFieldMatcher;
|
||||
dayOfWeek: CronFieldMatcher;
|
||||
}
|
||||
|
||||
interface CronDateParts {
|
||||
minute: number;
|
||||
hour: number;
|
||||
@@ -89,21 +9,6 @@ interface CronDateParts {
|
||||
dayOfWeek: number;
|
||||
}
|
||||
|
||||
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], { min: 0, max: 59, name: "minute" }),
|
||||
hour: parseField(parts[1], { min: 0, max: 23, name: "hour" }),
|
||||
dayOfMonth: parseField(parts[2], { min: 1, max: 31, name: "day-of-month" }),
|
||||
month: parseField(parts[3], { min: 1, max: 12, name: "month" }),
|
||||
dayOfWeek: parseField(parts[4], { min: 0, max: 6, name: "day-of-week" }),
|
||||
};
|
||||
}
|
||||
|
||||
function startOfNextMinute(date: Date): Date {
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
|
||||
Reference in New Issue
Block a user