From 4aeae158ddcafdcada87d0cf4fcc5fa0302e8fa0 Mon Sep 17 00:00:00 2001 From: UseAgent Date: Sun, 18 Oct 2026 06:44:44 +0530 Subject: [PATCH 1/2] Preview a schedule's next runs from the scheduler before it is saved GET /api/automations/preview?cron=&timezone= answers {next_runs: [three ISO UTC times]} by the rule the scheduler fires by (repo.ts nextRunAt, then cron.ts nextOccurrence), so an editor can show what a schedule will do while a person types it, without computing run times of its own. The first run is this minute when it is an occurrence, as the tick would still fire a schedule enabled now. The zone defaults to UTC as a create's does; a cron that names no date gets no runs; a cron or zone the scheduler cannot read, or a cron longer than any a person types (512 characters), answers 400 with the service's plain error ("cron must be a valid 5-field expression", "timezone must be a valid IANA name"). The /api/schedules alias answers the same, and the route and fields match the hosted product's. --- backend/src/schedules/routes.ts | 12 ++++++++ backend/src/schedules/service.ts | 32 +++++++++++++++++++- backend/test/schedules.test.ts | 47 ++++++++++++++++++++++++++++++ docs-site/docs/api/automations.mdx | 1 + 4 files changed, 91 insertions(+), 1 deletion(-) 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.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`. | From 560ff8608050cb4ac39261bd6fe4ca06905c2a40 Mon Sep 17 00:00:00 2001 From: UseAgent Date: Sun, 18 Oct 2026 10:23:03 +0530 Subject: [PATCH 2/2] Walk the days a clock change could bring back instead of jumping them nextOccurrence passes a run of days the cron rules out by calendar arithmetic and resumes at noon on the day before the next date it names. Across a change of half a day or more, or a clock set back past midnight, that jump could skip a real occurrence: from 2011-12-01, "0 0 31 12 *" in Pacific/Apia answered 2012 instead of 2011-12-30T10:00Z (Apia crossed the date line), and "* 23 4 3 *" in Antarctica/Casey skipped the 23:00 its 2010 change brought back. It now jumps only where a day-by-day walk would see the same dates: not while one of the two dates before the current one matches, and only when it lands on the day before the named date. Otherwise the walk takes the next day as before. The Apia, Kiritimati, Kwajalein and Casey changes give the walk's answers again, and a comparison with the walk over every zone, twelve date-restricted crons and starts from 1970 to 2035 (58,740 searches) found no difference; a cron that names no date still costs a fraction of a millisecond. --- backend/src/schedules/cron.ts | 38 +++++++++++++++++++---------- backend/test/schedules-cron.test.ts | 10 ++++++++ 2 files changed, 35 insertions(+), 13 deletions(-) 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/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();