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:
@@ -415,6 +415,10 @@ export function buildSessionsRoute() {
|
||||
return "/sessions" as const;
|
||||
}
|
||||
|
||||
export function buildSchedulesRoute() {
|
||||
return "/schedules" as const;
|
||||
}
|
||||
|
||||
export function buildOpenProjectRoute() {
|
||||
return "/open-project" as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
describeCron,
|
||||
everyMsToParts,
|
||||
formatCadence,
|
||||
formatNextRun,
|
||||
isNewAgentSchedule,
|
||||
partsToEveryMs,
|
||||
resolveScheduleTitle,
|
||||
validateCron,
|
||||
} from "./schedule-format";
|
||||
|
||||
function createSchedule(input: {
|
||||
name?: string | null;
|
||||
prompt?: string;
|
||||
title?: string | null;
|
||||
targetType?: "agent" | "new-agent";
|
||||
}): ScheduleSummary {
|
||||
return {
|
||||
id: "schedule-1",
|
||||
name: input.name ?? null,
|
||||
prompt: input.prompt ?? "Run the task",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target:
|
||||
input.targetType === "agent"
|
||||
? { type: "agent", agentId: "00000000-0000-4000-8000-000000000000" }
|
||||
: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
title: input.title,
|
||||
},
|
||||
},
|
||||
status: "active",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("schedule title helpers", () => {
|
||||
it("identifies new-agent schedules", () => {
|
||||
expect(isNewAgentSchedule(createSchedule({ targetType: "new-agent" }))).toBe(true);
|
||||
expect(isNewAgentSchedule(createSchedule({ targetType: "agent" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves display titles by name, config title, prompt, then fallback", () => {
|
||||
expect(
|
||||
resolveScheduleTitle(createSchedule({ name: "Morning run", title: "Config title" })),
|
||||
).toBe("Morning run");
|
||||
expect(resolveScheduleTitle(createSchedule({ name: " ", title: "Config title" }))).toBe(
|
||||
"Config title",
|
||||
);
|
||||
expect(
|
||||
resolveScheduleTitle(createSchedule({ name: " ", title: " ", prompt: "\nPrompt line" })),
|
||||
).toBe("Prompt line");
|
||||
expect(resolveScheduleTitle(createSchedule({ name: " ", title: " ", prompt: "\n " }))).toBe(
|
||||
"Untitled schedule",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interval formatting", () => {
|
||||
it("round-trips interval parts and formats cadence labels", () => {
|
||||
expect(everyMsToParts(2 * 24 * 60 * 60_000)).toEqual({ value: 2, unit: "days" });
|
||||
expect(everyMsToParts(3 * 60 * 60_000)).toEqual({ value: 3, unit: "hours" });
|
||||
expect(everyMsToParts(90_000)).toEqual({ value: 2, unit: "minutes" });
|
||||
expect(everyMsToParts(0)).toEqual({ value: 1, unit: "hours" });
|
||||
|
||||
expect(partsToEveryMs(2, "hours")).toBe(2 * 60 * 60_000);
|
||||
expect(partsToEveryMs(0, "minutes")).toBe(60_000);
|
||||
expect(formatCadence({ type: "every", everyMs: 2 * 60 * 60_000 })).toBe("Every 2 hours");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeCron", () => {
|
||||
it("humanizes common fixed-time cron shapes", () => {
|
||||
expect(describeCron("0 * * * *")).toBe("Every hour");
|
||||
expect(describeCron("15 * * * *")).toBe("Every hour at :15");
|
||||
expect(describeCron("0 9 * * *")).toBe("Daily at 09:00 UTC");
|
||||
expect(describeCron("0 9 * * 1-5")).toBe("Weekdays at 09:00 UTC");
|
||||
expect(describeCron("0 9 * * 0,6")).toBe("Weekends at 09:00 UTC");
|
||||
expect(describeCron("0 9 * * 1")).toBe("Mondays at 09:00 UTC");
|
||||
});
|
||||
|
||||
it("returns null for invalid or unrecognized valid cron expressions", () => {
|
||||
expect(describeCron("not a cron")).toBeNull();
|
||||
expect(describeCron("*/5 * * * *")).toBeNull();
|
||||
expect(formatCadence({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCron", () => {
|
||||
it("accepts structurally valid cron expressions", () => {
|
||||
expect(validateCron("*/5 9-17 * 1,6 1-5")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects step fields with extra slash tokens", () => {
|
||||
expect(validateCron("*/5/2 * * * *")).toBe("Invalid minute step");
|
||||
});
|
||||
|
||||
it("rejects malformed fields with targeted messages", () => {
|
||||
expect(validateCron("")).toBe("Enter a cron expression");
|
||||
expect(validateCron("* * *")).toBe("Cron expressions must have 5 fields");
|
||||
expect(validateCron("60 * * * *")).toBe("Invalid minute value");
|
||||
expect(validateCron("* 24 * * *")).toBe("Invalid hour value");
|
||||
expect(validateCron("* * 31-1 * *")).toBe("Invalid day-of-month range");
|
||||
expect(validateCron("* * * */0 *")).toBe("Invalid month step");
|
||||
expect(validateCron("* * * * mon")).toBe("Invalid day-of-week value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatNextRun", () => {
|
||||
it("formats next-run distance from the current clock", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
expect(formatNextRun(null)).toBe("");
|
||||
expect(formatNextRun("not-a-date")).toBe("");
|
||||
expect(formatNextRun("2026-01-01T00:00:15.000Z")).toBe("soon");
|
||||
expect(formatNextRun("2026-01-01T00:30:00.000Z")).toBe("in 30m");
|
||||
expect(formatNextRun("2026-01-01T03:00:00.000Z")).toBe("in 3h");
|
||||
expect(formatNextRun("2026-01-03T00:00:00.000Z")).toBe("in 2d");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user