Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions backend/src/schedules/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,18 @@ function skippedWallTimeMatches(p: ParsedCron, last: WallClock, gap: number): bo
return false;
}

/** Whether the day fields match the calendar date `days` after the wall-clock
* date of `wc` (before it when negative), by calendar arithmetic alone: a
* date's weekday is the same in every zone. */
function matchesDateFrom(p: ParsedCron, wc: WallClock, days: number): boolean {
const date = new Date(Date.UTC(wc.year, wc.month - 1, wc.dom + days));
return dayMatches(p, { dom: date.getUTCDate(), month: date.getUTCMonth() + 1, dow: date.getUTCDay() });
}

/** Days from the wall-clock date of `wc` to the next date whose day fields
* match, by calendar arithmetic alone (a date's weekday is the same in every
* zone); null when none comes within `maxDays`. */
* match; null when none comes within `maxDays`. */
function daysToMatchingDate(p: ParsedCron, wc: WallClock, maxDays: number): number | null {
for (let ahead = 1; ahead <= maxDays; ahead++) {
const date = new Date(Date.UTC(wc.year, wc.month - 1, wc.dom + ahead));
if (dayMatches(p, { dom: date.getUTCDate(), month: date.getUTCMonth() + 1, dow: date.getUTCDay() })) return ahead;
}
for (let ahead = 1; ahead <= maxDays; ahead++) if (matchesDateFrom(p, wc, ahead)) return ahead;
return null;
}

Expand Down Expand Up @@ -286,16 +290,24 @@ export function nextOccurrence(expr: string, timeZone: string, from: Date): Date
while (at <= end) {
if (occursAt(p, zone, at, wc, previousOffset)) return new Date(at);
// A run of days the day fields rule out is passed by calendar arithmetic:
// the walk resumes at noon on the day before the next date they name, a
// time no offset change moves off that (still unmatched) day.
if (!dayMatches(p, wc)) {
// the walk resumes at noon on the day before the next date they name. It
// jumps only where a day-by-day walk would see the same dates: not while
// one of the two dates before this one matches (a clock set back past
// midnight can bring it back), and only when it lands on that day before
// (otherwise an offset change of half a day or more, the date line, lies
// between). Where it does not jump, the walk below takes the next day.
if (!dayMatches(p, wc) && !matchesDateFrom(p, wc, -1) && !matchesDateFrom(p, wc, -2)) {
const ahead = daysToMatchingDate(p, wc, Math.ceil((end - at) / (MINUTES_PER_DAY * MINUTE_MS)) + 1);
if (ahead === null) return null;
if (ahead > 1) {
at = Date.UTC(wc.year, wc.month - 1, wc.dom + ahead - 1, 12) - wc.offset * MINUTE_MS;
previousOffset = wallClock(at - MINUTE_MS, zone).offset;
wc = wallClock(at, zone);
continue;
const target = Date.UTC(wc.year, wc.month - 1, wc.dom + ahead - 1, 12) - wc.offset * MINUTE_MS;
const landed = wallClock(target, zone);
if (target > at && Date.UTC(landed.year, landed.month - 1, landed.dom) === Date.UTC(wc.year, wc.month - 1, wc.dom + ahead - 1)) {
at = target;
previousOffset = wallClock(at - MINUTE_MS, zone).offset;
wc = landed;
continue;
}
}
}
// Skip to the next wall-clock midnight or hour when this day or hour cannot
Expand Down
12 changes: 12 additions & 0 deletions backend/src/schedules/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import {
createScheduleForOrg,
deleteScheduleForOrg,
previewNextRuns,
runScheduleNow,
ScheduleServiceError,
updateScheduleForOrg,
Expand All @@ -26,6 +27,17 @@ schedulesRoutes.get("/", async (c) => {
return c.json({ automations, schedules: automations });
});

// The next runs a cron and zone would have, before anything is saved: an
// editor's live preview, from the engine the scheduler fires by.
schedulesRoutes.get("/preview", (c) => {
try {
return c.json({ next_runs: previewNextRuns({ cron: c.req.query("cron"), timezone: c.req.query("timezone") }) });
} catch (error) {
if (error instanceof ScheduleServiceError) return c.json(error.body, error.status);
throw error;
}
});

// Create a schedule. `enabled` is always FALSE on create (KiroCrew's safety
// default) so a new schedule never auto-fires until explicitly enabled.
schedulesRoutes.post("/", runCreateBodyLimit, async (c) => {
Expand Down
32 changes: 31 additions & 1 deletion backend/src/schedules/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ import {
assertRunPromptLimit,
RunPromptTooLargeError,
} from "../commands/prompt-policy";
import { isValidCron, isValidTimezone } from "./cron";
import { isValidCron, isValidTimezone, nextOccurrence } from "./cron";
import {
createSchedule,
deleteSchedule,
getScheduleForOrg,
nextRunAt,
updateSchedule,
type ApiSchedule,
type ScheduleRecord,
Expand Down Expand Up @@ -363,6 +364,35 @@ export async function createScheduleForOrg(
return schedule;
}

/** How many runs a schedule preview names. */
const PREVIEW_RUNS = 3;
/** Longer than any cron a person types (every minute listed is 170 characters):
* the preview refuses more before parsing, as it answers while typing. */
const PREVIEW_CRON_MAX_LENGTH = 512;

/**
* The next runs a schedule with this cron and zone would have if enabled now,
* by the rule the scheduler fires by (repo.ts nextRunAt, then cron.ts
* nextOccurrence): what an editor shows while a person types a schedule,
* before anything is saved. The first is this minute when it is an
* occurrence, as the tick would still fire it. The zone defaults to UTC as a
* create's does; fewer runs (none) when the cron names fewer dates.
*/
export function previewNextRuns(query: { cron?: unknown; timezone?: unknown }, now: Date = new Date()): string[] {
const cron = typeof query.cron === "string" ? query.cron.trim() : "";
if (cron.length > PREVIEW_CRON_MAX_LENGTH || !isValidCron(cron)) {
throw new ScheduleServiceError(400, { error: "cron must be a valid 5-field expression" });
}
const timezone = parseTimezone(query.timezone) ?? "UTC";
const runs: string[] = [];
let next = nextRunAt({ cron, timezone, enabled: true, lastFiredAt: null }, now);
while (next && runs.length < PREVIEW_RUNS) {
runs.push(next.toISOString());
if (runs.length < PREVIEW_RUNS) next = nextOccurrence(cron, timezone, new Date(next.getTime() + 60_000));
}
return runs;
}

export function updateScheduleForOrg(
orgId: string,
id: string,
Expand Down
10 changes: 10 additions & 0 deletions backend/test/schedules-cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,16 @@ describe("rare expressions", () => {
expect(next("0 0 29 2 *", "Asia/Kolkata", "2026-10-16T00:00:00.000Z")).toBe("2028-02-28T18:30:00.000Z");
});

test("a clock that jumps across the date line or back over midnight is walked, not jumped", () => {
// Apia skipped 2011-12-30 (UTC-10 to UTC+14): midnight on the 31st is 10:00Z on the 30th.
expect(next("0 0 31 12 *", "Pacific/Apia", "2011-12-01T00:00:00.000Z")).toBe("2011-12-30T10:00:00.000Z");
expect(next("0 0 31 12 *", "Pacific/Kiritimati", "1994-12-01T00:00:00.000Z")).toBe("1994-12-31T10:00:00.000Z");
expect(next("0 12 21 8 *", "Pacific/Kwajalein", "1993-08-01T00:00:00.000Z")).toBe("1993-08-21T12:00:00.000Z");
expect(next("0 12 22 8 *", "Pacific/Kwajalein", "1993-08-01T00:00:00.000Z")).toBe("1993-08-22T00:00:00.000Z");
// Casey's 2010-03-05 change (UTC+11 to UTC+8 at 02:00) brought 23:00 on the 4th back.
expect(next("* 23 4 3 *", "Antarctica/Casey", "2010-03-04T13:00:00.000Z")).toBe("2010-03-04T15:00:00.000Z");
});

test("one search costs calendar arithmetic, not a minute walk through nine years", () => {
const started = performance.now();
for (let i = 0; i < 300; i++) expect(nextOccurrence("0 0 31 2 *", "America/New_York", new Date(Date.UTC(2026, 9, 18, 0, i)))).toBeNull();
Expand Down
47 changes: 47 additions & 0 deletions backend/test/schedules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { acceptRunCommand, type RunCommandIntent } from "../src/commands";
import { fireScheduleWithOutcome, firingKey } from "../src/schedules/fire";
import { getScheduleForOrg, nextRunAt } from "../src/schedules/repo";
import { AUTOMATION_RUN_ORIGIN } from "../src/runs/origin";
import { previewNextRuns } from "../src/schedules/service";
import { db } from "../src/db/client";
import { runs, skillRevisions } from "../src/db/schema";
import { createOrgSession, json, waitFor } from "./helpers";
Expand Down Expand Up @@ -70,6 +71,52 @@ async function createSkill(cookies: string): Promise<ApiSkill> {
}

describe("schedules API", () => {
test("the preview names the next three runs a cron and zone would have, with plain errors for what it cannot read", async () => {
const s = await createOrgSession("sched-preview");
const preview = (route: string, query: Record<string, string>) =>
json<{ next_runs?: string[]; error?: string }>(`${route}/preview?${new URLSearchParams(query)}`, { cookies: s.cookies });
const before = Date.now();
const weekdays = await preview("/api/automations", { cron: "0 9 * * 1-5", timezone: "Asia/Kolkata" });
expect(weekdays.status).toBe(200);
const runs = weekdays.body.next_runs ?? [];
expect(runs).toHaveLength(3);
const kolkata = new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Kolkata", weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
for (const run of runs) expect(kolkata.format(new Date(run))).toMatch(/^(Mon|Tue|Wed|Thu|Fri) 09:00$/);
expect(Date.parse(runs[0]!)).toBeGreaterThanOrEqual(before - 60_000);
expect(new Set(runs).size).toBe(3);
expect(runs.toSorted()).toEqual(runs);
// The alias answers the same; no zone means UTC, as a create without one.
const utc = await preview("/api/schedules", { cron: "30 6 * * *" });
expect(utc.status).toBe(200);
for (const run of utc.body.next_runs ?? []) expect(run.slice(11, 16)).toBe("06:30");
// A cron that names no date has no runs.
const never = await preview("/api/automations", { cron: "0 0 31 2 *", timezone: "UTC" });
expect(never.status).toBe(200);
expect(never.body.next_runs).toEqual([]);
const badCron = await preview("/api/automations", { cron: "not cron", timezone: "UTC" });
expect(badCron.status).toBe(400);
expect(badCron.body.error).toBe("cron must be a valid 5-field expression");
expect((await preview("/api/automations", {})).status).toBe(400);
const badZone = await preview("/api/automations", { cron: "0 9 * * *", timezone: "Mars/Olympus" });
expect(badZone.status).toBe(400);
expect(badZone.body.error).toBe("timezone must be a valid IANA name");
// A cron no person types (a valid list run to 600 characters) is refused
// before anything parses it.
const long = await preview("/api/automations", { cron: `${"0,".repeat(300)}0 9 * * *`, timezone: "UTC" });
expect(long.status).toBe(400);
expect(long.body.error).toBe("cron must be a valid 5-field expression");
});

test("the preview starts in the current minute, as the tick would fire a schedule enabled now", () => {
expect(previewNextRuns({ cron: "0 9 * * *", timezone: "UTC" }, new Date("2026-10-18T09:00:10Z")))
.toEqual(["2026-10-18T09:00:00.000Z", "2026-10-19T09:00:00.000Z", "2026-10-20T09:00:00.000Z"]);
});

test("the preview follows the scheduler's rule: a fixed time once across Lord Howe's half-hour fall-back", () => {
expect(previewNextRuns({ cron: "45 1 * * *", timezone: "Australia/Lord_Howe" }, new Date("2026-04-04T14:00:00Z")))
.toEqual(["2026-04-04T14:45:00.000Z", "2026-04-05T15:15:00.000Z", "2026-04-06T15:15:00.000Z"]);
});

test("canonical automations route creates and lists with the automation envelope", async () => {
const s = await createOrgSession("automation-route");

Expand Down
1 change: 1 addition & 0 deletions docs-site/docs/api/automations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ when the cron names no date.
| Method | Path | Description | Notes |
| --- | --- | --- | --- |
| GET | `/api/automations` | List the org's automations, newest first. | Returns `{automations, schedules}` (the same array under both keys for resumed clients). |
| GET | `/api/automations/preview` | The next three runs a schedule would have, before it is saved: an editor's live preview, from the engine the scheduler fires by. | Query `cron` (five fields) and `timezone` (IANA, `UTC` when omitted). `200 {next_runs}` (ISO UTC, fewer when the cron names fewer dates); `400 {error}` for a cron or zone it cannot read. |
| POST | `/api/automations` | Create an automation (created disabled). | JSON body validated by the service. `201` created object; `400` on invalid JSON. |
| PATCH | `/api/automations/:id` | Update an automation (enable/disable or edit). | Partial JSON body. `200` updated object. `403 automation_creator_only` for anyone but the creator (an admin may pause). |
| POST | `/api/automations/:id/run-now` | Fire immediately: create a real run and record a manual firing. | `201 {run_id}`. `403 automation_creator_only` for anyone but the creator. `404 schedule not found`. |
Expand Down
Loading