diff --git a/backend/src/schedules/cron.ts b/backend/src/schedules/cron.ts index 993129f3..47188c26 100644 --- a/backend/src/schedules/cron.ts +++ b/backend/src/schedules/cron.ts @@ -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; } @@ -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 diff --git a/backend/src/schedules/routes.ts b/backend/src/schedules/routes.ts index c150b032..e556df25 100644 --- a/backend/src/schedules/routes.ts +++ b/backend/src/schedules/routes.ts @@ -9,6 +9,7 @@ import { import { createScheduleForOrg, deleteScheduleForOrg, + previewNextRuns, runScheduleNow, ScheduleServiceError, updateScheduleForOrg, @@ -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) => { diff --git a/backend/src/schedules/service.ts b/backend/src/schedules/service.ts index 3c0a8ebe..3e4f0259 100644 --- a/backend/src/schedules/service.ts +++ b/backend/src/schedules/service.ts @@ -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, @@ -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, diff --git a/backend/test/schedules-cron.test.ts b/backend/test/schedules-cron.test.ts index 1c9ba26d..01032038 100644 --- a/backend/test/schedules-cron.test.ts +++ b/backend/test/schedules-cron.test.ts @@ -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(); diff --git a/backend/test/schedules.test.ts b/backend/test/schedules.test.ts index 836023f2..5fa04943 100644 --- a/backend/test/schedules.test.ts +++ b/backend/test/schedules.test.ts @@ -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"; @@ -70,6 +71,52 @@ async function createSkill(cookies: string): Promise { } 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) => + 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"); diff --git a/docs-site/docs/api/automations.mdx b/docs-site/docs/api/automations.mdx index a9e804e6..9589026a 100644 --- a/docs-site/docs/api/automations.mdx +++ b/docs-site/docs/api/automations.mdx @@ -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`. |