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
+184
View File
@@ -0,0 +1,184 @@
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { validateCronExpression } from "@getpaseo/protocol/schedule/cron-expression";
export type IntervalUnit = "minutes" | "hours" | "days";
const MS_PER_MINUTE = 60_000;
const MS_PER_HOUR = MS_PER_MINUTE * 60;
const MS_PER_DAY = MS_PER_HOUR * 24;
const UNIT_MS: Record<IntervalUnit, number> = {
minutes: MS_PER_MINUTE,
hours: MS_PER_HOUR,
days: MS_PER_DAY,
};
const DAY_NAMES = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
export function isNewAgentSchedule(schedule: ScheduleSummary): boolean {
return schedule.target.type === "new-agent";
}
export function resolveScheduleTitle(schedule: ScheduleSummary): string {
const name = schedule.name?.trim();
if (name) {
return name;
}
if (schedule.target.type === "new-agent") {
const configTitle = schedule.target.config.title?.trim();
if (configTitle) {
return configTitle;
}
}
const firstPromptLine = schedule.prompt
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0);
return firstPromptLine || "Untitled schedule";
}
function pluralize(value: number, noun: string): string {
return value === 1 ? `1 ${noun}` : `${value} ${noun}s`;
}
export function everyMsToParts(ms: number): { value: number; unit: IntervalUnit } {
if (!Number.isFinite(ms) || ms <= 0) {
return { value: 1, unit: "hours" };
}
if (ms % MS_PER_DAY === 0) {
return { value: ms / MS_PER_DAY, unit: "days" };
}
if (ms % MS_PER_HOUR === 0) {
return { value: ms / MS_PER_HOUR, unit: "hours" };
}
return { value: Math.max(1, Math.round(ms / MS_PER_MINUTE)), unit: "minutes" };
}
export function partsToEveryMs(value: number, unit: IntervalUnit): number {
const normalized = Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1;
return normalized * UNIT_MS[unit];
}
const UNIT_NOUN: Record<IntervalUnit, string> = {
minutes: "minute",
hours: "hour",
days: "day",
};
function formatEvery(everyMs: number): string {
const { value, unit } = everyMsToParts(everyMs);
return `Every ${pluralize(value, UNIT_NOUN[unit])}`;
}
export function formatCadence(cadence: ScheduleCadence): string {
if (cadence.type === "every") {
return formatEvery(cadence.everyMs);
}
return describeCron(cadence.expression) ?? cadence.expression;
}
/**
* Humanize a handful of common 5-field cron shapes. Returns null when the
* expression is valid but not one of the recognized patterns (callers fall
* back to showing the raw expression).
*/
export function describeCron(expr: string): string | null {
const trimmed = expr.trim();
if (validateCron(trimmed) !== null) {
return null;
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = trimmed.split(/\s+/);
// Only humanize the simple "fixed time" family: literal minute/hour with the
// date fields either wildcarded or a recognized day-of-week constraint.
const minuteNum = Number.parseInt(minute, 10);
const isLiteralMinute = /^\d+$/.test(minute);
const isWildcardMonth = month === "*";
const isWildcardDom = dayOfMonth === "*";
if (!isLiteralMinute || !isWildcardMonth || !isWildcardDom) {
return null;
}
// "Every hour" / "Every hour at :MM"
if (hour === "*") {
if (dayOfWeek !== "*") {
return null;
}
return minuteNum === 0 ? "Every hour" : `Every hour at :${pad2(minuteNum)}`;
}
if (!/^\d+$/.test(hour)) {
return null;
}
const time = `${pad2(Number.parseInt(hour, 10))}:${pad2(minuteNum)}`;
if (dayOfWeek === "*") {
return `Daily at ${time} UTC`;
}
if (dayOfWeek === "1-5") {
return `Weekdays at ${time} UTC`;
}
if (dayOfWeek === "0,6" || dayOfWeek === "6,0") {
return `Weekends at ${time} UTC`;
}
if (/^\d$/.test(dayOfWeek)) {
const day = DAY_NAMES[Number.parseInt(dayOfWeek, 10)];
if (day) {
return `${day}s at ${time} UTC`;
}
}
return null;
}
export function validateCron(expr: string): string | null {
const trimmed = expr.trim();
if (!trimmed) {
return "Enter a cron expression";
}
const error = validateCronExpression(trimmed);
return error?.replace(/^Invalid cron /, "Invalid ") ?? null;
}
function pad2(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
/**
* Forward-relative description of the next run, e.g. "in 3h", "in 2d", "soon".
* Returns "" when there is no scheduled next run.
*/
export function formatNextRun(iso: string | null): string {
if (!iso) {
return "";
}
const target = new Date(iso).getTime();
if (Number.isNaN(target)) {
return "";
}
const diffMs = target - Date.now();
if (diffMs <= 0) {
return "soon";
}
if (diffMs < MS_PER_MINUTE) {
return "soon";
}
if (diffMs < MS_PER_HOUR) {
return `in ${Math.round(diffMs / MS_PER_MINUTE)}m`;
}
if (diffMs < MS_PER_DAY) {
return `in ${Math.round(diffMs / MS_PER_HOUR)}h`;
}
return `in ${Math.round(diffMs / MS_PER_DAY)}d`;
}