Files
paseo/packages/app/src/utils/schedule-format.test.ts
T
Mohamed BoudraandGitHub bf5e3b47e7 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.
2026-06-30 22:58:49 +02:00

136 lines
4.9 KiB
TypeScript

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");
});
});