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:
Mohamed Boudra
2026-06-30 22:58:49 +02:00
committed by GitHub
parent b8b66816ca
commit bf5e3b47e7
36 changed files with 3622 additions and 146 deletions
@@ -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 -96
View File
@@ -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(