From 359e8bb8f4676db659eee0e1e72a0f0876ce83c4 Mon Sep 17 00:00:00 2001 From: UseAgent Date: Sun, 18 Oct 2026 05:08:36 +0530 Subject: [PATCH] Name a zone on every schedule and return when it runs next The backend decides when a routine fires, but the API never said when the next run is, so a client had to rebuild the cron and timezone math itself, and a schedule saved without a zone ran on the server's local clock, which nobody could see. The scheduler now fires by one occurrence rule, the engine the hosted product runs (cron.ts nextOccurrence). The tick fires a schedule in a minute that rule names (isOccurrence, one minute's check however rare the cron) unless it fired it there already, and schedules/repo.ts nextRunAt names the next such minute, which the automations API returns as next_due_at and a bot's routines as nextDueAt. A search passes the days a cron rules out by calendar arithmetic, so a rare cron costs a fraction of a millisecond rather than a walk through years. Across a DST change a fixed time now fires once at its first reading of a repeated hour and right after a skipped one, where before it fired twice in a fall-back and never in a spring-forward gap. Every schedule names its zone. Migration 0114 writes the zone the backend's clock runs in (the pool names it in useagent.backend_time_zone) into every schedule saved without one, so each keeps firing at the times it did, and makes the column NOT NULL; applied without that setting it refuses rather than guess. The previous backend serves until the swap and still saves a schedule sent without a zone with none: a trigger gives that write the same zone. The migration takes its table lock first, so it never deadlocks with a writer that already holds a row lock. The engine no longer reads a server-local clock, and a zone Intl cannot read names no run. A create that sends no zone (an API client, an agent's tool call) runs in UTC, stored and returned; the web app sends the creator's browser zone. A PATCH may change the zone; one that names none (a client still holding a schedule from before the backfill sends null) leaves it as it is. --- backend/drizzle/0114_schedule_zones.sql | 42 +++ backend/drizzle/meta/_journal.json | 7 + backend/src/bots/routes.ts | 6 +- backend/src/db/client.ts | 8 +- backend/src/db/schema/schedules.ts | 12 +- .../gateway/automation-tool-catalog.ts | 4 +- .../src/knowledge/gateway/automation-tools.ts | 3 +- backend/src/schedules/cron.ts | 272 +++++++++++++----- backend/src/schedules/repo.ts | 35 ++- backend/src/schedules/scheduler.ts | 20 +- backend/src/schedules/service.ts | 16 +- backend/test/automation-creator.test.ts | 2 +- backend/test/automation-spend.test.ts | 2 +- backend/test/bots-routines.test.ts | 28 ++ backend/test/schedule-zones-migration.test.ts | 191 ++++++++++++ backend/test/schedules-cron.test.ts | 269 ++++++++++++++--- backend/test/schedules-idempotency.test.ts | 52 ++-- backend/test/schedules.test.ts | 127 +++++++- docs-site/docs/api/automations.mdx | 7 + docs-site/docs/api/bots.mdx | 4 +- 20 files changed, 936 insertions(+), 171 deletions(-) create mode 100644 backend/drizzle/0114_schedule_zones.sql create mode 100644 backend/test/schedule-zones-migration.test.ts diff --git a/backend/drizzle/0114_schedule_zones.sql b/backend/drizzle/0114_schedule_zones.sql new file mode 100644 index 000000000..f42d626b5 --- /dev/null +++ b/backend/drizzle/0114_schedule_zones.sql @@ -0,0 +1,42 @@ +-- fast-deploy: expansion-safe +-- Every schedule names the zone its cron runs in. One saved without a zone ran +-- on its backend's own clock. The backend migrates at boot, and the release +-- script runs under the backend's env, so the pool applying this names that +-- clock's zone in useagent.backend_time_zone (src/db/client.ts): the clock +-- such a schedule would read after the swap anyway. Each gets that zone here +-- and keeps firing at the times it did. Applied without the setting (a scratch +-- database, a tool), this refuses any schedule that needs a zone rather than +-- guess one. +-- +-- The previous backend serves until the swap and still saves a schedule sent +-- without a zone, or one cleared, with none. The trigger gives that write the +-- same zone, so the column can be NOT NULL now. +-- +-- The table lock comes first and is the strongest this needs (SET NOT NULL's): +-- holding nothing while it waits, the migration never deadlocks with a writer +-- that locked a row and has its UPDATE still to run, and once held no write +-- slips in between the backfill and the constraint. +LOCK TABLE "schedules" IN ACCESS EXCLUSIVE MODE; +--> statement-breakpoint +DO $$ +DECLARE + zone text := nullif(current_setting('useagent.backend_time_zone', true), ''); +BEGIN + IF zone IS NULL AND EXISTS (SELECT 1 FROM "schedules" WHERE "timezone" IS NULL) THEN + RAISE EXCEPTION 'schedules saved without a zone ran on the backend''s clock: apply migrations through the backend, which names that zone in useagent.backend_time_zone'; + END IF; + EXECUTE format( + 'CREATE OR REPLACE FUNCTION "schedules_timezone_fill"() RETURNS trigger LANGUAGE plpgsql AS %L', + format('BEGIN NEW."timezone" := %L; RETURN NEW; END', zone) + ); +END +$$; +--> statement-breakpoint +DROP TRIGGER IF EXISTS "schedules_timezone_fill" ON "schedules"; +--> statement-breakpoint +CREATE TRIGGER "schedules_timezone_fill" BEFORE INSERT OR UPDATE OF "timezone" ON "schedules" + FOR EACH ROW WHEN (NEW."timezone" IS NULL) EXECUTE FUNCTION "schedules_timezone_fill"(); +--> statement-breakpoint +UPDATE "schedules" SET "timezone" = current_setting('useagent.backend_time_zone', true) WHERE "timezone" IS NULL; +--> statement-breakpoint +ALTER TABLE "schedules" ALTER COLUMN "timezone" SET NOT NULL; diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index aba28c395..9f5caa7fc 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -757,6 +757,13 @@ "when": 1792800000023, "tag": "0113_runtime_artifact_verifications", "breakpoints": true + }, + { + "idx": 109, + "version": "7", + "when": 1792800000024, + "tag": "0114_schedule_zones", + "breakpoints": true } ] } diff --git a/backend/src/bots/routes.ts b/backend/src/bots/routes.ts index c987e9e13..3ac29aa60 100644 --- a/backend/src/bots/routes.ts +++ b/backend/src/bots/routes.ts @@ -31,7 +31,7 @@ import type { BotRow } from "../db/schema"; import { asOrgAdmin } from "../provider-connections/repo"; import { memberDisplayNames } from "../team/member-names"; import { botsEnabled } from "./rollout"; -import { listFirings, markFired } from "../schedules/repo"; +import { listFirings, markFired, nextRunAt } from "../schedules/repo"; import { createScheduleForOrg, deleteScheduleForOrg, @@ -265,7 +265,7 @@ function routineView(row: { id: string; name: string; cron: string; - timezone: string | null; + timezone: string; prompt: string; enabled: boolean; lastFiredAt: Date | null; @@ -279,6 +279,8 @@ function routineView(row: { prompt: row.prompt, enabled: row.enabled, lastFiredAt: row.lastFiredAt ? row.lastFiredAt.toISOString() : null, + // The minute the scheduler fires it next (schedules/repo.ts nextRunAt). + nextDueAt: nextRunAt(row, new Date())?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), }; } diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index aa2b881ec..1b33f9012 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -2,10 +2,14 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./schema"; -// Single shared connection pool for the whole process. +// Single shared connection pool for the whole process. Its connections name +// the zone this process's clock runs in, which a migration applied here reads +// (drizzle/0114_schedule_zones.sql): the backend migrates at boot and the +// release script runs under the backend's own env, so it is the clock the +// backend reads local times in. export const client = postgres( process.env.DATABASE_URL ?? "postgres://postgres@localhost:5432/useagent", - { max: 10 }, + { max: 10, connection: { "useagent.backend_time_zone": Intl.DateTimeFormat().resolvedOptions().timeZone } }, ); export const db = drizzle(client, { schema }); diff --git a/backend/src/db/schema/schedules.ts b/backend/src/db/schema/schedules.ts index 97757654f..14a8b344f 100644 --- a/backend/src/db/schema/schedules.ts +++ b/backend/src/db/schema/schedules.ts @@ -32,13 +32,13 @@ export const schedules = pgTable( orgId: text("org_id").notNull(), userId: text("user_id"), name: text("name").notNull(), - // 5-field cron expression ("min hour dom month dow"). Evaluated in - // `timezone` (below) when set, else in the scheduler's server-local time. + // 5-field cron expression ("min hour dom month dow"), evaluated in `timezone`. cron: text("cron").notNull(), - // IANA timezone the cron is evaluated in ("America/New_York"). Null = server - // local time (the pre-timezone behavior). Cloudflare-Scheduler parity: a - // schedule's wall-clock intent is explicit, not tied to where the box runs. - timezone: text("timezone"), + // IANA timezone the cron is evaluated in ("America/New_York"). Every schedule + // names one, so its wall-clock intent never depends on where the box runs; + // drizzle/0114_schedule_zones.sql gave the ones saved without a zone the zone + // their backend's clock read. + timezone: text("timezone").notNull(), prompt: text("prompt").notNull(), engine: text("engine").$type().notNull().default("mock"), model: text("model").notNull().default("claude-opus-5"), diff --git a/backend/src/knowledge/gateway/automation-tool-catalog.ts b/backend/src/knowledge/gateway/automation-tool-catalog.ts index b9db3ec37..8facdd38f 100644 --- a/backend/src/knowledge/gateway/automation-tool-catalog.ts +++ b/backend/src/knowledge/gateway/automation-tool-catalog.ts @@ -41,7 +41,7 @@ export const AUTOMATION_TOOLS = [ properties: { name: { type: "string", description: "Human-readable automation name." }, cron: { type: "string", description: "Five-field cron expression." }, - timezone: { type: "string", description: "Optional IANA timezone, for example Asia/Kolkata." }, + timezone: { type: "string", description: "IANA timezone the cron runs in, for example Asia/Kolkata. UTC when omitted." }, prompt: { type: "string", description: "Prompt to run when the automation fires." }, engine: { type: "string", description: "Optional UseAgent engine id." }, model: { type: "string", description: "Optional model id allowed for the selected engine." }, @@ -84,7 +84,7 @@ export const AUTOMATION_TOOLS = [ id: { type: "string", description: "Automation id returned by automation_list/create." }, name: { type: "string" }, cron: { type: "string" }, - timezone: { type: ["string", "null"], description: "IANA timezone, or null/empty string to clear." }, + timezone: { type: "string", description: "IANA timezone the cron runs in." }, prompt: { type: "string" }, engine: { type: "string" }, model: { type: "string" }, diff --git a/backend/src/knowledge/gateway/automation-tools.ts b/backend/src/knowledge/gateway/automation-tools.ts index 5507a7589..9ce640100 100644 --- a/backend/src/knowledge/gateway/automation-tools.ts +++ b/backend/src/knowledge/gateway/automation-tools.ts @@ -13,7 +13,7 @@ import { AUTOMATION_TOOLS as AUTOMATION_TOOL_CATALOG, } from "./automation-tool-catalog"; import { getRunForOrg } from "../../runs/repo"; -import { getScheduleForOrg, listFirings, listSchedules, type ApiFiring, type ApiSchedule, type ScheduleRecord } from "../../schedules/repo"; +import { getScheduleForOrg, listFirings, listSchedules, nextRunAt, type ApiFiring, type ApiSchedule, type ScheduleRecord } from "../../schedules/repo"; import { createScheduleForOrg, deleteScheduleForOrg, @@ -161,6 +161,7 @@ function automationSummary(schedule: ApiSchedule | ScheduleRecord): Record | nul return out; } -/** Split + validate a 5-field expression into its expanded sets, or `null`. */ -function parse(expr: string): { +interface ParsedCron { minute: Set; hour: Set; dom: Set; @@ -63,7 +62,13 @@ function parse(expr: string): { dow: Set; domRestricted: boolean; dowRestricted: boolean; -} | null { + /** Neither the minute nor the hour field starts with `*`: the expression + * names wall-clock times rather than a period (Vixie cron's distinction). */ + fixedTime: boolean; +} + +/** Split + validate a 5-field expression into its expanded sets, or `null`. */ +function parse(expr: string): ParsedCron | null { const fields = expr.trim().split(/\s+/); if (fields.length !== 5) return null; const [minF, hourF, domF, monF, dowF] = fields as [ @@ -93,18 +98,25 @@ function parse(expr: string): { dow, domRestricted: domF !== "*", dowRestricted: dowF !== "*", + fixedTime: !minF.startsWith("*") && !hourF.startsWith("*"), }; } -/** The cron-relevant wall-clock fields of an instant. */ +/** The cron-relevant wall-clock fields of an instant, and the zone's offset there. */ interface WallClock { - minute: number; - hour: number; - dom: number; + year: number; month: number; + dom: number; + hour: number; + minute: number; dow: number; // 0 = Sunday … 6 = Saturday (matches Date.getDay) + /** Minutes east of UTC at this instant. */ + offset: number; } +const MINUTE_MS = 60_000; +const MINUTES_PER_DAY = 24 * 60; + const WEEKDAY: Record = { Sun: 0, Mon: 1, @@ -115,76 +127,208 @@ const WEEKDAY: Record = { Sat: 6, }; -/** Wall-clock fields in server LOCAL time (the pre-timezone behavior). */ -function localWallClock(date: Date): WallClock { +/** One formatter per zone (building one is the expensive part); null for a + * zone Intl rejects. */ +const zoneFormatters = new Map(); + +function zoneFormatter(timeZone: string): Intl.DateTimeFormat | null { + let formatter = zoneFormatters.get(timeZone); + if (formatter === undefined) { + try { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + weekday: "short", + }); + } catch { + formatter = null; + } + zoneFormatters.set(timeZone, formatter); + } + return formatter; +} + +/** Wall-clock fields of the instant `at` (epoch ms), read by a zone's formatter. */ +function wallClock(at: number, zone: Intl.DateTimeFormat): WallClock { + const parts = zone.formatToParts(at); + const get = (type: string): string => parts.find((p) => p.type === type)?.value ?? ""; + const wall = { + year: Number(get("year")), + month: Number(get("month")), + dom: Number(get("day")), + hour: Number(get("hour")), + minute: Number(get("minute")), + }; + const utcMinute = Math.floor(at / MINUTE_MS) * MINUTE_MS; return { - minute: date.getMinutes(), - hour: date.getHours(), - dom: date.getDate(), - month: date.getMonth() + 1, - dow: date.getDay(), + ...wall, + dow: WEEKDAY[get("weekday")] ?? new Date(at).getUTCDay(), + offset: (Date.UTC(wall.year, wall.month - 1, wall.dom, wall.hour, wall.minute) - utcMinute) / MINUTE_MS, }; } -/** - * Wall-clock fields of `date` as seen in IANA `timeZone`, dependency-free via - * `Intl.DateTimeFormat`. An invalid/unknown zone throws when the formatter is - * built — we fall back to server local time (defensive: a bad zone must never - * break the whole scheduler tick; the API validates the zone before it persists). - */ -function zonedWallClock(date: Date, timeZone: string): WallClock { - try { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - hourCycle: "h23", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - weekday: "short", - }).formatToParts(date); - const get = (type: string): string => - parts.find((p) => p.type === type)?.value ?? ""; - return { - minute: Number(get("minute")), - hour: Number(get("hour")), - dom: Number(get("day")), - month: Number(get("month")), - dow: WEEKDAY[get("weekday")] ?? date.getUTCDay(), - }; - } catch { - return localWallClock(date); - } +function dayMatches(p: ParsedCron, wc: Pick): boolean { + if (!p.month.has(wc.month)) return false; + const domMatch = p.dom.has(wc.dom); + const dowMatch = p.dow.has(wc.dow); + // Vixie cron: when BOTH day-of-month and day-of-week are restricted, the day + // matches if EITHER does; otherwise a restricted field must match (a `*` + // field never restricts). + if (p.domRestricted && p.dowRestricted) return domMatch || dowMatch; + return domMatch && dowMatch; +} + +function fieldsMatch(p: ParsedCron, wc: Pick): boolean { + return p.minute.has(wc.minute) && p.hour.has(wc.hour) && dayMatches(p, wc); } /** - * True if `date` matches the 5-field cron `expr`. Evaluated in `timeZone` (an - * IANA name) when given, else in server local time. The timezone makes a - * schedule's wall-clock intent independent of where the box runs (mem_op 0.4). + * True if `date`'s wall clock in `timeZone` (an IANA name) matches the 5-field + * cron `expr`; false for a zone Intl cannot read. Every schedule names its + * zone, so its wall-clock intent never depends on where the box runs. This is + * the field matcher; which instants are occurrences (DST repeats and gaps + * included) is {@link nextOccurrence}'s answer. */ -export function cronMatches( - expr: string, - date: Date, - timeZone?: string | null, -): boolean { +export function cronMatches(expr: string, date: Date, timeZone: string): boolean { const p = parse(expr); - if (!p) return false; + const zone = zoneFormatter(timeZone); + return p && zone ? fieldsMatch(p, wallClock(date.getTime(), zone)) : false; +} - const wc = timeZone ? zonedWallClock(date, timeZone) : localWallClock(date); - if (!p.minute.has(wc.minute)) return false; - if (!p.hour.has(wc.hour)) return false; - if (!p.month.has(wc.month)) return false; +/** Far enough ahead to pass the longest gap a valid expression can have: + * February 29 across 2100, eight years. */ +const OCCURRENCE_HORIZON_MS = 9 * 366 * MINUTES_PER_DAY * MINUTE_MS; +/** Longer than any DST shift (Antarctica/Troll moves two hours). */ +const SHIFT_LOOKBACK_MS = 3 * 60 * MINUTE_MS; - const domMatch = p.dom.has(wc.dom); - const dowMatch = p.dow.has(wc.dow); +/** A fall-back read this wall time once already: the zone's offset dropped by + * `shift` within the lookback, and the instant `shift` earlier still had the + * old offset, so it showed the same wall clock. */ +function repeatsEarlierWallTime(at: number, wc: WallClock, zone: Intl.DateTimeFormat): boolean { + const before = wallClock(at - SHIFT_LOOKBACK_MS, zone); + const shift = before.offset - wc.offset; + return shift > 0 && wallClock(at - shift * MINUTE_MS, zone).offset === before.offset; +} - // Vixie cron: when BOTH day-of-month and day-of-week are restricted, the day - // matches if EITHER does; otherwise a restricted field must match (a `*` - // field never restricts). - if (p.domRestricted && p.dowRestricted) { - return domMatch || dowMatch; +/** A spring-forward skipped `gap` wall-clock minutes just before this instant; + * does one of them match? `last` is the wall clock a minute earlier. */ +function skippedWallTimeMatches(p: ParsedCron, last: WallClock, gap: number): boolean { + const lastMinute = Date.UTC(last.year, last.month - 1, last.dom, last.hour, last.minute); + for (let k = 1; k <= gap; k++) { + const skipped = new Date(lastMinute + k * MINUTE_MS); // calendar arithmetic, read back in UTC + if ( + fieldsMatch(p, { + minute: skipped.getUTCMinutes(), + hour: skipped.getUTCHours(), + dom: skipped.getUTCDate(), + month: skipped.getUTCMonth() + 1, + dow: skipped.getUTCDay(), + }) + ) { + return true; + } } - return domMatch && dowMatch; + return false; +} + +/** 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`. */ +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; + } + return null; +} + +/** Whether the whole minute `at` (its wall clock `wc`; the zone's offset a + * minute earlier `previousOffset`) is an occurrence of `p`: the rule + * nextOccurrence applies to each minute it passes and isOccurrence to one. */ +function occursAt(p: ParsedCron, zone: Intl.DateTimeFormat, at: number, wc: WallClock, previousOffset: number): boolean { + if (fieldsMatch(p, wc)) return !p.fixedTime || !repeatsEarlierWallTime(at, wc, zone); + return ( + p.fixedTime && + wc.offset > previousOffset && + skippedWallTimeMatches(p, wallClock(at - MINUTE_MS, zone), wc.offset - previousOffset) + ); +} + +/** + * The first occurrence of `expr` at or after `from` (rounded up to a whole + * minute), or null when it has none within the horizon (February 30) or Intl + * cannot read `timeZone`. The one definition of an occurrence: the scheduler + * fires in the minutes it names (isOccurrence asks the same of one minute), + * and the API shows the next of them as a schedule's next run. + * + * An occurrence is a whole minute whose wall clock in `timeZone` matches the + * fields (the same matcher as {@link cronMatches}). Across a DST change a + * fixed-time expression (no `*` in the minute or hour field) fires once per + * named wall-clock time: in a fall-back only the first reading of a repeated + * time counts, and a time a spring-forward skips fires at the first minute + * after the gap. A periodic expression keeps real time: it fires in both + * readings of a repeated hour and nothing stands in for skipped minutes. + * That is Vixie cron's rule. + */ +export function nextOccurrence(expr: string, timeZone: string, from: Date): Date | null { + const p = parse(expr); + const zone = zoneFormatter(timeZone); + if (!p || !zone) return null; + const end = from.getTime() + OCCURRENCE_HORIZON_MS; + let at = Math.ceil(from.getTime() / MINUTE_MS) * MINUTE_MS; + let previousOffset = wallClock(at - MINUTE_MS, zone).offset; + let wc = wallClock(at, zone); + 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)) { + 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; + } + } + // Skip to the next wall-clock midnight or hour when this day or hour cannot + // match, but only when the offset is the same at both ends: then no DST + // change lies between, every minute passed read the same day or hour, and + // none was a skipped or repeated time. Otherwise step one minute. + const span = !dayMatches(p, wc) + ? MINUTES_PER_DAY - (wc.hour * 60 + wc.minute) + : p.hour.has(wc.hour) + ? 1 + : 60 - wc.minute; + let step = span; + let next = wallClock(at + span * MINUTE_MS, zone); + if (span > 1 && next.offset !== wc.offset) { + step = 1; + next = wallClock(at + MINUTE_MS, zone); + } + previousOffset = step === 1 ? wc.offset : next.offset; + at += step * MINUTE_MS; + wc = next; + } + return null; +} + +/** True if the whole minute containing `at` is an occurrence of `expr` in + * `timeZone`, the minute nextOccurrence would return from its start: the + * scheduler tick's check, one minute's work however rare the expression. */ +export function isOccurrence(expr: string, timeZone: string, at: Date): boolean { + const p = parse(expr); + const zone = zoneFormatter(timeZone); + if (!p || !zone) return false; + const minute = Math.floor(at.getTime() / MINUTE_MS) * MINUTE_MS; + return occursAt(p, zone, minute, wallClock(minute, zone), wallClock(minute - MINUTE_MS, zone).offset); } /** True if `expr` is a syntactically valid 5-field cron expression. */ diff --git a/backend/src/schedules/repo.ts b/backend/src/schedules/repo.ts index aafdb6cf9..d3a84856d 100644 --- a/backend/src/schedules/repo.ts +++ b/backend/src/schedules/repo.ts @@ -8,6 +8,7 @@ import { type EngineId, type ScheduleTrigger, } from "../db/schema"; +import { nextOccurrence } from "./cron"; // --------------------------------------------------------------------------- // API serialization — snake_case wire contract, matching the runs/skills style. @@ -22,8 +23,8 @@ export interface ApiSchedule { user_id: string | null; name: string; cron: string; - /** IANA timezone the cron runs in; null = server local time. */ - timezone: string | null; + /** IANA timezone the cron runs in. */ + timezone: string; prompt: string; engine: EngineId; model: string; @@ -44,6 +45,9 @@ export interface ApiSchedule { enabled: boolean; /** The cron loop's last tick for this schedule (its same-minute guard). */ last_fired_at: string | null; + /** The next run: the minute the scheduler fires it next (nextRunAt), in the + * schedule's timezone. Null while disabled or when the cron names no date. */ + next_due_at: string | null; /** When it last actually ran, by any trigger (cron or Run now): the newest * firing in the log, falling back to last_fired_at. What a card shows. */ last_run_at: string | null; @@ -64,6 +68,30 @@ export interface ApiFiring { run_summary: string | null; } +const MINUTE_MS = 60_000; + +/** The tick already fired `s` in the minute `now` falls in (its same-minute + * guard): last_fired_at lies in that very minute, not a later one. */ +export function firedThisMinute(s: Pick, now: Date): boolean { + return s.lastFiredAt !== null && Math.floor(s.lastFiredAt.getTime() / MINUTE_MS) === Math.floor(now.getTime() / MINUTE_MS); +} + +/** + * When the scheduler fires `s` next: its first occurrence (cron.ts + * nextOccurrence) from the start of the minute `now` falls in, or from the next + * minute once the tick fired it in this one. Null while disabled. The tick fires + * a schedule in a minute that is an occurrence (cron.ts isOccurrence, the same + * rule) unless it fired it there already, so the two cannot disagree. + */ +export function nextRunAt( + s: Pick, + now: Date, +): Date | null { + if (!s.enabled) return null; + const minute = Math.floor(now.getTime() / MINUTE_MS) * MINUTE_MS; + return nextOccurrence(s.cron, s.timezone, new Date(firedThisMinute(s, now) ? minute + MINUTE_MS : minute)); +} + function toSchedule(s: ScheduleRecord, lastRunAt: Date | null = s.lastFiredAt): ApiSchedule { return { id: s.id, @@ -91,6 +119,7 @@ function toSchedule(s: ScheduleRecord, lastRunAt: Date | null = s.lastFiredAt): enablement_policy: s.enablementPolicy, enabled: s.enabled, last_fired_at: s.lastFiredAt ? s.lastFiredAt.toISOString() : null, + next_due_at: nextRunAt(s, new Date())?.toISOString() ?? null, last_run_at: (lastRunAt ?? s.lastFiredAt)?.toISOString() ?? null, created_at: s.createdAt.toISOString(), updated_at: s.updatedAt.toISOString(), @@ -136,7 +165,7 @@ export async function createSchedule(input: { userId: string | null; name: string; cron: string; - timezone: string | null; + timezone: string; prompt: string; engine: EngineId; model: string; diff --git a/backend/src/schedules/scheduler.ts b/backend/src/schedules/scheduler.ts index 3aa0d1d71..cba51d1e8 100644 --- a/backend/src/schedules/scheduler.ts +++ b/backend/src/schedules/scheduler.ts @@ -6,8 +6,8 @@ // this is the Postgres-backed translation — a plain 60s interval tick. import { botsEnabled } from "../bots/rollout"; -import { cronMatches } from "./cron"; -import { listEnabledSchedules, markFired } from "./repo"; +import { isOccurrence } from "./cron"; +import { firedThisMinute, listEnabledSchedules, markFired } from "./repo"; import { fireScheduleForOrg } from "./service"; const TICK_MS = process.env.SCHEDULER_TICK_MS @@ -16,14 +16,13 @@ const TICK_MS = process.env.SCHEDULER_TICK_MS let timer: ReturnType | null = null; -/** Two dates fall in the same clock minute (epoch-minute bucket). */ -function sameMinute(a: Date, b: Date): boolean { - return Math.floor(a.getTime() / 60_000) === Math.floor(b.getTime() / 60_000); -} - /** - * One scheduler pass: fire every enabled schedule whose cron matches the - * current minute and that hasn't already fired this minute. `markFired` stamps + * One scheduler pass: fire every enabled schedule whose occurrence (cron.ts + * isOccurrence, the rule nextOccurrence and so the API's next run follow) is + * the current minute and that the tick has not fired in it yet. One minute's + * check per schedule, however rare its cron. Across a DST change a fixed time + * fires once, at its first reading of a repeated hour or right after a skipped + * one. `markFired` stamps * the minute AFTER the durable command is accepted — stamping first opened a * loss window (accept fails after the stamp → that occurrence is skipped * forever; external audit finding). Duplicates from the reverse ordering are @@ -41,8 +40,7 @@ export async function tick(now: Date = new Date()): Promise { } for (const s of due) { - if (!cronMatches(s.cron, now, s.timezone)) continue; - if (s.lastFiredAt && sameMinute(new Date(s.lastFiredAt), now)) continue; + if (!isOccurrence(s.cron, s.timezone, now) || firedThisMinute(s, now)) continue; // Bot routines sit out while bots are switched off; they resume on the next // matching minute after the switch flips back. if (s.botId && !botsEnabled(s.orgId)) continue; diff --git a/backend/src/schedules/service.ts b/backend/src/schedules/service.ts index d55d2cc5b..3c0a8ebee 100644 --- a/backend/src/schedules/service.ts +++ b/backend/src/schedules/service.ts @@ -193,9 +193,9 @@ async function parseSkillPin( }; } -function parseTimezone(value: unknown): string | null | undefined { - if (value === undefined) return undefined; - if (value === null || value === "") return null; +/** The zone a request names, validated; none for absent, null or "". */ +function parseTimezone(value: unknown): string | undefined { + if (value === undefined || value === null || value === "") return undefined; const timezone = typeof value === "string" ? value.trim() : ""; if (!isValidTimezone(timezone)) { throw new ScheduleServiceError(400, { error: "timezone must be a valid IANA name" }); @@ -300,7 +300,10 @@ export async function createScheduleForOrg( if (!prompt) throw new ScheduleServiceError(400, { error: "prompt is required" }); assertSchedulePromptLimit(prompt); - const timezone = parseTimezone(body.timezone) ?? null; + // Every schedule names the zone its cron runs in. A create that sends none + // (an API client, an agent's tool call) runs in UTC; the web app sends its + // creator's browser zone. + const timezone = parseTimezone(body.timezone) ?? "UTC"; // Creation is an inert draft operation. Persist valid engine/model intent // even while a paid provider is temporarily unavailable; readiness is // enforced at enable and by the shared command lane at execution time. @@ -377,7 +380,7 @@ async function updateSchedulePatch( const patch: Partial<{ name: string; cron: string; - timezone: string | null; + timezone: string; prompt: string; engine: EngineId; model: string; @@ -399,6 +402,9 @@ async function updateSchedulePatch( if (typeof body.enabled === "boolean") patch.enabled = body.enabled; + // A patch may move the schedule to another zone. One that names none (a + // client still holding the schedule from before every schedule named one + // sends null) leaves the zone as it is. const timezone = parseTimezone(body.timezone); if (timezone !== undefined) patch.timezone = timezone; diff --git a/backend/test/automation-creator.test.ts b/backend/test/automation-creator.test.ts index 7d12a2674..919e87552 100644 --- a/backend/test/automation-creator.test.ts +++ b/backend/test/automation-creator.test.ts @@ -202,7 +202,7 @@ describe("an automation answers to its creator", () => { const t = await team("auto-legacy"); const bot = await json<{ bot: { id: string } }>("/api/bots", { method: "POST", cookies: t.creator.cookies, body: { name: "Desk", engine: "mock" } }); const [legacy] = await db.insert(schedules).values({ - orgId: t.owner.orgId, userId: t.teammateId, name: "Mine", cron: "* * * * *", prompt: "Read the mail.", engine: "mock", model: "m", + orgId: t.owner.orgId, userId: t.teammateId, name: "Mine", cron: "* * * * *", timezone: "UTC", prompt: "Read the mail.", engine: "mock", model: "m", botId: bot.body.bot.id, enabled: true, }).returning(); const refused = await json<{ error: string; message: string }>(`/api/schedules/${legacy!.id}/run-now`, { method: "POST", cookies: t.teammate.cookies }); diff --git a/backend/test/automation-spend.test.ts b/backend/test/automation-spend.test.ts index a7d97977e..c882679e5 100644 --- a/backend/test/automation-spend.test.ts +++ b/backend/test/automation-spend.test.ts @@ -31,7 +31,7 @@ test("automation_run_now for a capped owner returns the allowance refusal as a s const orgId = `org-spend-${crypto.randomUUID()}`; const userId = `user-spend-${crypto.randomUUID()}`; const schedule = await createSchedule({ - orgId, userId, name: "nightly digest", cron: "0 0 * * *", timezone: null, prompt: "summarise the day", + orgId, userId, name: "nightly digest", cron: "0 0 * * *", timezone: "UTC", prompt: "summarise the day", engine: "mock", model: defaultModelForEngine("mock"), skillId: null, skillVersion: null, skillContentHash: null, repos: [], tags: [], delivery: null, notifications: null, runActorId: userId, concurrency: null, queue: null, costLimits: null, frequencyLimits: null, approvalPolicy: null, diff --git a/backend/test/bots-routines.test.ts b/backend/test/bots-routines.test.ts index 227d7526d..26d819021 100644 --- a/backend/test/bots-routines.test.ts +++ b/backend/test/bots-routines.test.ts @@ -33,8 +33,10 @@ interface RoutineBody { id: string; name: string; cron: string; + timezone: string; prompt: string; enabled: boolean; + nextDueAt: string | null; } async function createBot(cookies: string, name: string): Promise { @@ -158,6 +160,32 @@ describe("bot routines", () => { expect(badCron.status).toBe(400); }); + test("a routine runs in the zone it was saved with, UTC when none was given, and lists the minute it fires next", async () => { + const { cookies } = await createOrgSession("bot-routines-zone"); + const bot = await createBot(cookies, "Clockwork"); + const create = (body: Record) => + json<{ routine: RoutineBody }>(`/api/bots/${bot.id}/routines`, { method: "POST", cookies, body }); + const zoned = await create({ name: "Island digest", cron: "45 1 * * *", timezone: "Australia/Lord_Howe", prompt: "Digest." }); + const unzoned = await create({ name: "Plain digest", cron: "0 9 * * *", prompt: "Digest." }); + try { + expect(zoned.status).toBe(201); + expect(unzoned.status).toBe(201); + expect(unzoned.body.routine.timezone).toBe("UTC"); + const listed = await json<{ routines: RoutineBody[] }>(`/api/bots/${bot.id}/routines`, { cookies }); + const island = listed.body.routines.find((routine) => routine.id === zoned.body.routine.id)!; + expect(island.timezone).toBe("Australia/Lord_Howe"); + // 01:45 on the island's clock, whichever side of its half-hour DST change. + const wall = new Intl.DateTimeFormat("en-GB", { timeZone: "Australia/Lord_Howe", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); + expect(wall.format(new Date(island.nextDueAt!))).toBe("01:45"); + expect(Date.parse(island.nextDueAt!)).toBeGreaterThan(Date.now() - 60_000); + expect(Date.parse(island.nextDueAt!)).toBeLessThan(Date.now() + 25 * 3_600_000); + } finally { + for (const created of [zoned, unzoned]) { + await json(`/api/bots/${bot.id}/routines/${created.body.routine.id}`, { method: "PATCH", cookies, body: { enabled: false } }); + } + } + }); + test("a retry recovers the winning home-thread follow-up after a first-fire race", async () => { const { cookies, orgId } = await createOrgSession("bot-routine-retarget-recovery"); const bot = await createBot(cookies, "Relay"); diff --git a/backend/test/schedule-zones-migration.test.ts b/backend/test/schedule-zones-migration.test.ts new file mode 100644 index 000000000..765d55004 --- /dev/null +++ b/backend/test/schedule-zones-migration.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { and, eq, sql } from "drizzle-orm"; +import postgres from "postgres"; +import { db } from "../src/db/client"; +import { scheduleFirings } from "../src/db/schema"; +import { getScheduleForOrg, type ApiSchedule } from "../src/schedules/repo"; +import { tick } from "../src/schedules/scheduler"; +import { createOrgSession, json, waitFor } from "./helpers"; +import { testDatabaseUrl } from "./test-database"; + +const MIGRATION = new URL("../drizzle/0114_schedule_zones.sql", import.meta.url); +/** The zone the suite's own clock runs in (bun test runs in UTC). */ +const OWN_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; + +/** Apply the zone migration as a backend whose clock runs in `timeZone` does: + * migrate() on the backend's pool in a fresh process started with that TZ, + * against a scratch record table so it applies again. Given OWN_ZONE, the + * suite's database ends as the boot migration left it. */ +async function applyZoneMigrationAs(timeZone: string): Promise { + const tag = crypto.randomUUID().slice(0, 8); + const folder = mkdtempSync(join(tmpdir(), "zone-migration-")); + try { + mkdirSync(join(folder, "meta")); + copyFileSync(MIGRATION, join(folder, "0000_zones.sql")); + writeFileSync(join(folder, "meta", "_journal.json"), JSON.stringify({ + version: "7", dialect: "postgresql", entries: [{ idx: 0, version: "7", when: 1, tag: "0000_zones", breakpoints: true }], + })); + const client = new URL("../src/db/client.ts", import.meta.url).pathname; + const backend = Bun.spawn({ + cmd: [process.execPath, "-e", `const { db } = await import(${JSON.stringify(client)}); + const { migrate } = await import("drizzle-orm/postgres-js/migrator"); + await migrate(db, { migrationsFolder: ${JSON.stringify(folder)}, migrationsTable: ${JSON.stringify(`zone_migrations_${tag}`)} }); + process.exit(0);`], + cwd: new URL("..", import.meta.url).pathname, + env: { ...process.env, TZ: timeZone, DATABASE_URL: testDatabaseUrl() }, + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([backend.exited, new Response(backend.stderr).text()]); + if (exitCode !== 0) throw new Error(`migration as a ${timeZone} backend exited ${exitCode}: ${stderr}`); + } finally { + await db.execute(sql`drop table if exists drizzle.${sql.identifier(`zone_migrations_${tag}`)}`); + rmSync(folder, { recursive: true, force: true }); + } +} + +/** An enabled schedule created through the API in `timezone`. */ +async function enabledSchedule(label: string, cron: string, timezone: string) { + const session = await createOrgSession(label); + const created = await json("/api/schedules", { + method: "POST", + cookies: session.cookies, + body: { name: label, cron, timezone, prompt: `prompt ${label}`, engine: "mock" }, + }); + expect(created.status).toBe(201); + const enabled = await json(`/api/schedules/${created.body.id}`, { + method: "PATCH", + cookies: session.cookies, + body: { enabled: true }, + }); + expect(enabled.status).toBe(200); + return { session, id: created.body.id }; +} + +async function disable(id: string): Promise { + await db.execute(sql`update schedules set enabled = false where id = ${id}`); +} + +async function cronFirings(id: string): Promise { + const rows = await db + .select({ id: scheduleFirings.id }) + .from(scheduleFirings) + .where(and(eq(scheduleFirings.scheduleId, id), eq(scheduleFirings.trigger, "cron"))); + return rows.length; +} + +/** Undo the migration's constraint and trigger, as the previous release had it. */ +async function asBeforeTheMigration(): Promise { + await db.execute(sql`alter table schedules alter column timezone drop not null`); + await db.execute(sql`drop trigger if exists schedules_timezone_fill on schedules`); +} + +describe("schedule zone migration", () => { + test("a schedule saved without a zone gets its backend's zone and fires when it did", async () => { + await asBeforeTheMigration(); + const legacy = await enabledSchedule("zone-legacy", "45 1 * * *", "UTC"); + const zoned = await enabledSchedule("zone-kept", "0 9 * * *", "Asia/Kolkata"); + await db.execute(sql`update schedules set timezone = null where id = ${legacy.id}`); + let olderBackendRow: string | null = null; + try { + // A backend whose own clock reads Lord Howe time (a half-hour DST change) + // applies it the way a release does. + await applyZoneMigrationAs("Australia/Lord_Howe"); + expect((await getScheduleForOrg(legacy.session.orgId, legacy.id))?.timezone).toBe("Australia/Lord_Howe"); + expect((await getScheduleForOrg(zoned.session.orgId, zoned.id))?.timezone).toBe("Asia/Kolkata"); + const [column] = await db.execute<{ nullable: string }>(sql` + select is_nullable as nullable from information_schema.columns where table_name = 'schedules' and column_name = 'timezone'`); + expect(column?.nullable).toBe("NO"); + + // It fires on the clock it fired on before: 01:45 Lord Howe daylight time + // (14:45Z), once, though the half-hour fall-back repeats 01:45 at 15:15Z. + await tick(new Date("2026-04-04T14:45:10Z")); + await tick(new Date("2026-04-04T15:15:10Z")); + expect(await cronFirings(legacy.id)).toBe(1); + + // The previous backend serves until the swap and still saves a schedule + // sent without a zone, or clears one, with none: each gets the same zone. + const [inserted] = await db.execute<{ id: string; timezone: string }>(sql` + insert into schedules (org_id, name, cron, timezone, prompt) + values (${zoned.session.orgId}, 'older backend', '0 9 * * *', null, 'x') returning id, timezone`); + olderBackendRow = inserted?.id ?? null; + expect(inserted?.timezone).toBe("Australia/Lord_Howe"); + await db.execute(sql`update schedules set timezone = null where id = ${zoned.id}`); + expect((await getScheduleForOrg(zoned.session.orgId, zoned.id))?.timezone).toBe("Australia/Lord_Howe"); + } finally { + await disable(legacy.id); + await disable(zoned.id); + if (olderBackendRow) await db.execute(sql`delete from schedules where id = ${olderBackendRow}`); + await applyZoneMigrationAs(OWN_ZONE); + } + }); + + test("it goes through beside a writer that holds a schedule's row lock with its UPDATE still to run", async () => { + await asBeforeTheMigration(); + const legacy = await enabledSchedule("zone-locked-writer", "0 9 * * *", "UTC"); + await db.execute(sql`update schedules set timezone = null where id = ${legacy.id}`); + const olderBackend = postgres(testDatabaseUrl(), { max: 1, onnotice: () => {} }); + const { promise: locked, resolve: rowLocked } = Promise.withResolvers(); + const { promise: go, resolve: write } = Promise.withResolvers(); + let migrated: Promise | null = null; + try { + const writer = olderBackend.begin(async (tx) => { + await tx`select id from schedules where id = ${legacy.id} for update`; + const [backend] = await tx<{ pid: number }[]>`select pg_backend_pid() as pid`; + rowLocked(Number(backend!.pid)); + await go; + await tx`update schedules set name = 'renamed by the older backend', timezone = null, updated_at = now() + where id = ${legacy.id}`; + }); + const pid = await locked; + migrated = applyZoneMigrationAs("America/New_York"); + // The migration is parked behind the writer before the writer goes on. + await waitFor(async () => { + const [row] = await db.execute<{ waiting: number }>(sql` + select count(*)::int as waiting from pg_stat_activity where ${pid}::int = any(pg_blocking_pids(pid))`); + return Number(row?.waiting) > 0; + }); + write(); + await writer; + await migrated; + const row = await getScheduleForOrg(legacy.session.orgId, legacy.id); + expect(row?.name).toBe("renamed by the older backend"); + expect(row?.timezone).toBe("America/New_York"); + } finally { + write(); + await migrated?.catch(() => {}); + await olderBackend.end({ timeout: 5 }); + await disable(legacy.id); + await applyZoneMigrationAs(OWN_ZONE); + } + }); + + test("applied outside a backend it guesses no zone: it refuses rows that need one", async () => { + const migration = await readFile(MIGRATION, "utf8"); + // A pool that names no backend zone, like a scratch database migrated by a + // test or a tool. With every schedule zoned there is nothing to name. + const scratch = postgres(testDatabaseUrl(), { max: 1, onnotice: () => {} }); + const apply = () => + scratch.begin(async (tx) => { + for (const statement of migration.split("--> statement-breakpoint")) await tx.unsafe(statement); + }); + await apply(); + await asBeforeTheMigration(); + const legacy = await enabledSchedule("zone-unknown-clock", "0 9 * * *", "UTC"); + await db.execute(sql`update schedules set timezone = null where id = ${legacy.id}`); + try { + expect(await apply().then(() => "applied", (error: unknown) => String(error))).toContain("useagent.backend_time_zone"); + const [row] = await db.execute<{ timezone: string | null }>(sql`select timezone from schedules where id = ${legacy.id}`); + expect(row?.timezone).toBeNull(); + } finally { + await db.execute(sql`update schedules set timezone = 'UTC' where id = ${legacy.id}`); + await disable(legacy.id); + await scratch.end({ timeout: 5 }); + await applyZoneMigrationAs(OWN_ZONE); + } + }); +}); diff --git a/backend/test/schedules-cron.test.ts b/backend/test/schedules-cron.test.ts index 4973d314d..1c9ba26d7 100644 --- a/backend/test/schedules-cron.test.ts +++ b/backend/test/schedules-cron.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { cronMatches, isValidCron } from "../src/schedules/cron"; +import { cronMatches, isOccurrence, isValidCron, nextOccurrence } from "../src/schedules/cron"; -/** Build a local-time Date at the given fields (month is 1-based here). */ +/** The UTC instant at the given wall-clock fields (month is 1-based here). */ function at( year: number, month: number, @@ -9,107 +9,110 @@ function at( hour: number, minute: number, ): Date { - return new Date(year, month - 1, day, hour, minute, 0, 0); + return new Date(Date.UTC(year, month - 1, day, hour, minute)); } +/** The field matcher, read in UTC. */ +const matches = (expr: string, date: Date) => cronMatches(expr, date, "UTC"); + describe("cron matcher", () => { test("every-minute wildcard matches any time", () => { - expect(cronMatches("* * * * *", at(2026, 8, 5, 13, 37))).toBe(true); - expect(cronMatches("* * * * *", at(2026, 1, 1, 0, 0))).toBe(true); + expect(matches("* * * * *", at(2026, 8, 5, 13, 37))).toBe(true); + expect(matches("* * * * *", at(2026, 1, 1, 0, 0))).toBe(true); }); test("exact minute+hour", () => { // 2026-08-05 is a Wednesday (dow 3). - expect(cronMatches("30 2 * * *", at(2026, 8, 5, 2, 30))).toBe(true); - expect(cronMatches("30 2 * * *", at(2026, 8, 5, 2, 31))).toBe(false); - expect(cronMatches("30 2 * * *", at(2026, 8, 5, 3, 30))).toBe(false); + expect(matches("30 2 * * *", at(2026, 8, 5, 2, 30))).toBe(true); + expect(matches("30 2 * * *", at(2026, 8, 5, 2, 31))).toBe(false); + expect(matches("30 2 * * *", at(2026, 8, 5, 3, 30))).toBe(false); }); test("*/N step on minutes", () => { const expr = "*/15 * * * *"; - expect(cronMatches(expr, at(2026, 8, 5, 9, 0))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 9, 15))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 9, 30))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 9, 45))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 9, 7))).toBe(false); - expect(cronMatches(expr, at(2026, 8, 5, 9, 46))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 9, 0))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 9, 15))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 9, 30))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 9, 45))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 9, 7))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 9, 46))).toBe(false); }); test("bare-value step (5/10) counts from the start value", () => { const expr = "5/10 * * * *"; for (const m of [5, 15, 25, 35, 45, 55]) { - expect(cronMatches(expr, at(2026, 8, 5, 9, m))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 9, m))).toBe(true); } - expect(cronMatches(expr, at(2026, 8, 5, 9, 0))).toBe(false); - expect(cronMatches(expr, at(2026, 8, 5, 9, 10))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 9, 0))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 9, 10))).toBe(false); }); test("ranges on hours", () => { const expr = "0 9-17 * * *"; - expect(cronMatches(expr, at(2026, 8, 5, 9, 0))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 17, 0))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 8, 0))).toBe(false); - expect(cronMatches(expr, at(2026, 8, 5, 18, 0))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 9, 0))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 17, 0))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 8, 0))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 18, 0))).toBe(false); }); test("range with step (0-30/10)", () => { const expr = "0-30/10 * * * *"; for (const m of [0, 10, 20, 30]) { - expect(cronMatches(expr, at(2026, 8, 5, 9, m))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 9, m))).toBe(true); } - expect(cronMatches(expr, at(2026, 8, 5, 9, 40))).toBe(false); - expect(cronMatches(expr, at(2026, 8, 5, 9, 5))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 9, 40))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 9, 5))).toBe(false); }); test("comma lists", () => { const expr = "0,30 8,12,18 * * *"; - expect(cronMatches(expr, at(2026, 8, 5, 8, 0))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 12, 30))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 18, 0))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 5, 8, 15))).toBe(false); - expect(cronMatches(expr, at(2026, 8, 5, 9, 0))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 8, 0))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 12, 30))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 18, 0))).toBe(true); + expect(matches(expr, at(2026, 8, 5, 8, 15))).toBe(false); + expect(matches(expr, at(2026, 8, 5, 9, 0))).toBe(false); }); test("mixed list of ranges and steps", () => { const expr = "0 0 * * 1-5"; // weekdays midnight // 2026-08-05 Wed, 2026-08-08 Sat, 2026-08-09 Sun - expect(cronMatches(expr, at(2026, 8, 5, 0, 0))).toBe(true); // Wed - expect(cronMatches(expr, at(2026, 8, 8, 0, 0))).toBe(false); // Sat - expect(cronMatches(expr, at(2026, 8, 9, 0, 0))).toBe(false); // Sun + expect(matches(expr, at(2026, 8, 5, 0, 0))).toBe(true); // Wed + expect(matches(expr, at(2026, 8, 8, 0, 0))).toBe(false); // Sat + expect(matches(expr, at(2026, 8, 9, 0, 0))).toBe(false); // Sun }); test("day-of-week: 0 and 7 both mean Sunday", () => { // 2026-08-09 is a Sunday. - expect(cronMatches("0 12 * * 0", at(2026, 8, 9, 12, 0))).toBe(true); - expect(cronMatches("0 12 * * 7", at(2026, 8, 9, 12, 0))).toBe(true); - expect(cronMatches("0 12 * * 0", at(2026, 8, 5, 12, 0))).toBe(false); // Wed + expect(matches("0 12 * * 0", at(2026, 8, 9, 12, 0))).toBe(true); + expect(matches("0 12 * * 7", at(2026, 8, 9, 12, 0))).toBe(true); + expect(matches("0 12 * * 0", at(2026, 8, 5, 12, 0))).toBe(false); // Wed }); test("day-of-month matching", () => { const expr = "0 0 1 * *"; // first of the month - expect(cronMatches(expr, at(2026, 8, 1, 0, 0))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 2, 0, 0))).toBe(false); + expect(matches(expr, at(2026, 8, 1, 0, 0))).toBe(true); + expect(matches(expr, at(2026, 8, 2, 0, 0))).toBe(false); }); test("month field", () => { const expr = "0 0 1 1 *"; // Jan 1 - expect(cronMatches(expr, at(2026, 1, 1, 0, 0))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 1, 0, 0))).toBe(false); + expect(matches(expr, at(2026, 1, 1, 0, 0))).toBe(true); + expect(matches(expr, at(2026, 8, 1, 0, 0))).toBe(false); }); test("dom+dow OR semantics (both restricted → either matches)", () => { // "1st of month OR any Monday". 2026-08-01 is a Saturday (dom match only); // 2026-08-03 is a Monday (dow match only); 2026-08-04 is a Tuesday (neither). const expr = "0 0 1 * 1"; - expect(cronMatches(expr, at(2026, 8, 1, 0, 0))).toBe(true); // dom hit - expect(cronMatches(expr, at(2026, 8, 3, 0, 0))).toBe(true); // dow hit (Mon) - expect(cronMatches(expr, at(2026, 8, 4, 0, 0))).toBe(false); // neither + expect(matches(expr, at(2026, 8, 1, 0, 0))).toBe(true); // dom hit + expect(matches(expr, at(2026, 8, 3, 0, 0))).toBe(true); // dow hit (Mon) + expect(matches(expr, at(2026, 8, 4, 0, 0))).toBe(false); // neither }); test("dom restricted, dow wildcard → AND (dow never restricts)", () => { const expr = "0 0 15 * *"; - expect(cronMatches(expr, at(2026, 8, 15, 0, 0))).toBe(true); - expect(cronMatches(expr, at(2026, 8, 16, 0, 0))).toBe(false); + expect(matches(expr, at(2026, 8, 15, 0, 0))).toBe(true); + expect(matches(expr, at(2026, 8, 16, 0, 0))).toBe(false); }); test("isValidCron accepts good expressions", () => { @@ -140,7 +143,183 @@ describe("cron matcher", () => { "a * * * *", // non-numeric ]) { expect(isValidCron(e)).toBe(false); - expect(cronMatches(e, at(2026, 8, 5, 0, 0))).toBe(false); + expect(matches(e, at(2026, 8, 5, 0, 0))).toBe(false); + } + }); +}); + +/** The next occurrence at or after `from`, as an ISO string (null for none). */ +function next(expr: string, timeZone: string, from: string): string | null { + return nextOccurrence(expr, timeZone, new Date(from))?.toISOString() ?? null; +} + +/** Every occurrence in [from, to), stepping through nextOccurrence. */ +function occurrences(expr: string, timeZone: string, from: string, to: string): string[] { + const out: string[] = []; + const end = new Date(to).getTime(); + for (let at = nextOccurrence(expr, timeZone, new Date(from)); at && at.getTime() < end; + at = nextOccurrence(expr, timeZone, new Date(at.getTime() + 60_000))) { + out.push(at.toISOString()); + } + return out; +} + +describe("occurrences (nextOccurrence)", () => { + test("from rounds up to a whole minute and is inclusive", () => { + expect(next("* * * * *", "UTC", "2026-10-16T12:00:00.000Z")).toBe("2026-10-16T12:00:00.000Z"); + expect(next("* * * * *", "UTC", "2026-10-16T12:00:00.001Z")).toBe("2026-10-16T12:01:00.000Z"); + expect(next("0 0 * * *", "UTC", "2026-10-16T23:30:00.000Z")).toBe("2026-10-17T00:00:00.000Z"); + expect(next("0 9 * * *", "Asia/Kolkata", "2026-10-16T04:00:00.000Z")).toBe("2026-10-17T03:30:00.000Z"); + }); + + test("an expression that names no date has none; a rare one is still found", () => { + expect(next("0 0 31 2 *", "UTC", "2026-10-16T00:00:00.000Z")).toBeNull(); + expect(next("not cron", "UTC", "2026-10-16T00:00:00.000Z")).toBeNull(); + expect(next("0 0 29 2 *", "UTC", "2026-10-16T00:00:00.000Z")).toBe("2028-02-29T00:00:00.000Z"); + expect(next("0 0 29 2 *", "UTC", "2096-03-01T00:00:00.000Z")).toBe("2104-02-29T00:00:00.000Z"); + }); + + // The cases where a second copy of the schedule math could disagree with the + // scheduler; here one function answers for the scheduler and the API alike. + test("fall-back: a fixed time repeated by the change fires once, at its first reading", () => { + // New York 2026-11-01: 01:30 EDT is 05:30Z, 01:30 EST is 06:30Z. + expect(next("30 1 * * *", "America/New_York", "2026-11-01T05:00:00.000Z")).toBe("2026-11-01T05:30:00.000Z"); + expect(next("30 1 * * *", "America/New_York", "2026-11-01T05:45:00.000Z")).toBe("2026-11-02T06:30:00.000Z"); + // Berlin 2026-10-25: 02:00-02:59 CEST is 00:00-00:59Z, CET 01:00-01:59Z. + expect(occurrences("0,45 2 * * *", "Europe/Berlin", "2026-10-24T12:00:00.000Z", "2026-10-26T12:00:00.000Z")).toEqual([ + "2026-10-25T00:00:00.000Z", + "2026-10-25T00:45:00.000Z", + "2026-10-26T01:00:00.000Z", + "2026-10-26T01:45:00.000Z", + ]); + // Lord Howe moves 30 minutes: 01:45 LHDT is 14:45Z, 01:45 LHST 15:15Z. + expect(next("45 1 * * *", "Australia/Lord_Howe", "2026-04-04T14:00:00.000Z")).toBe("2026-04-04T14:45:00.000Z"); + expect(next("45 1 * * *", "Australia/Lord_Howe", "2026-04-04T14:46:00.000Z")).toBe("2026-04-05T15:15:00.000Z"); + }); + + test("fall-back: a periodic expression keeps real time through the repeated hour", () => { + expect(occurrences("*/30 * * * *", "America/New_York", "2026-11-01T05:00:00.000Z", "2026-11-01T07:01:00.000Z")).toEqual([ + "2026-11-01T05:00:00.000Z", // 01:00 EDT + "2026-11-01T05:30:00.000Z", // 01:30 EDT + "2026-11-01T06:00:00.000Z", // 01:00 EST + "2026-11-01T06:30:00.000Z", // 01:30 EST + "2026-11-01T07:00:00.000Z", // 02:00 EST + ]); + }); + + test("spring-forward: a fixed time the change skips fires at the first minute after the gap", () => { + // New York 2026-03-08: 02:00-02:59 does not exist; 03:00 EDT is 07:00Z. + expect(occurrences("30 2 * * *", "America/New_York", "2026-03-07T00:00:00.000Z", "2026-03-10T00:00:00.000Z")).toEqual([ + "2026-03-07T07:30:00.000Z", + "2026-03-08T07:00:00.000Z", + "2026-03-09T06:30:00.000Z", + ]); + // A skipped time and a real one landing on the same minute are one occurrence. + expect(occurrences("0 2,3 * * *", "America/New_York", "2026-03-08T00:00:00.000Z", "2026-03-08T12:00:00.000Z")).toEqual([ + "2026-03-08T07:00:00.000Z", + ]); + // Lord Howe 2026-10-04: 02:00 LHST (15:30Z) becomes 02:30 LHDT. + expect(next("15 2 * * *", "Australia/Lord_Howe", "2026-10-03T14:00:00.000Z")).toBe("2026-10-03T15:30:00.000Z"); + }); + + test("spring-forward: a periodic expression does not stand in for skipped minutes", () => { + expect(occurrences("*/30 * * * *", "America/New_York", "2026-03-08T06:00:00.000Z", "2026-03-08T07:31:00.000Z")).toEqual([ + "2026-03-08T06:00:00.000Z", // 01:00 EST + "2026-03-08T06:30:00.000Z", // 01:30 EST + "2026-03-08T07:00:00.000Z", // 03:00 EDT + "2026-03-08T07:30:00.000Z", // 03:30 EDT + ]); + }); + + test("occurrences are exactly the minutes the field matcher accepts, apart from a fixed time's DST repeats and gaps", () => { + const expressions = [ + "* * * * *", + "*/7 * * * *", + "0 * * * *", + "30 9 * * 1-5", + "15 3 1,15 * *", + "0 0 * * 0", + "5/10 0-12/2 1-28 */3 0-6", + "0 12 1 * 1", + "* 2 * * *", + "0 1-3 * * *", + ]; + const zones = ["UTC", "America/New_York", "Europe/Berlin", "Asia/Kolkata", "Asia/Kathmandu", "Australia/Lord_Howe"]; + // Two windows, each across a change in the northern or the southern zones. + const windows = [ + [Date.UTC(2026, 2, 6), Date.UTC(2026, 2, 10)], + [Date.UTC(2026, 9, 2), Date.UTC(2026, 9, 6)], + [Date.UTC(2026, 9, 24), Date.UTC(2026, 10, 3)], + ] as const; + for (const zone of zones) { + for (const expr of expressions) { + const fixedTime = !/^\*/.test(expr.split(" ")[0]!) && !/^\*/.test(expr.split(" ")[1]!); + for (const [start, end] of windows) { + let due = nextOccurrence(expr, zone, new Date(start))?.getTime(); + for (let at = start; at < end; at += 60_000) { + const isOccurrence = at === due; + if (isOccurrence) due = nextOccurrence(expr, zone, new Date(at + 60_000))?.getTime(); + const matches = cronMatches(expr, new Date(at), zone); + if (isOccurrence === matches) continue; + // Only a fixed time across a change may differ, and only as the rule says. + expect(fixedTime).toBe(true); + const offsetNow = Math.round((Date.parse(new Date(at).toLocaleString("en-US", { timeZone: zone })) - Date.parse(new Date(at).toLocaleString("en-US", { timeZone: "UTC" }))) / 60_000); + const offsetBefore = Math.round((Date.parse(new Date(at - 3 * 3_600_000).toLocaleString("en-US", { timeZone: zone })) - Date.parse(new Date(at - 3 * 3_600_000).toLocaleString("en-US", { timeZone: "UTC" }))) / 60_000); + expect(offsetNow).not.toBe(offsetBefore); + } + } + } } }); }); + +describe("rare expressions", () => { + test("days that cannot match are passed without changing what comes next", () => { + // The first Sunday of November 2026 is the New York fall-back: 01:30 EDT. + expect(next("30 1 * 11 0", "America/New_York", "2026-10-18T00:00:00.000Z")).toBe("2026-11-01T05:30:00.000Z"); + // The first Sunday of March 2027 is still standard time. + expect(next("15 2 * 3 0", "America/New_York", "2026-10-18T00:00:00.000Z")).toBe("2027-03-07T07:15:00.000Z"); + // 2027-03-14 02:15 is skipped by the spring-forward: the first minute after it. + expect(next("15 2 14 3 *", "America/New_York", "2026-10-18T00:00:00.000Z")).toBe("2027-03-14T07:00:00.000Z"); + // Lord Howe's half-hour fall-back on 2027-04-04: 01:45 LHDT, the first reading. + expect(next("45 1 4 4 *", "Australia/Lord_Howe", "2026-10-18T00:00:00.000Z")).toBe("2027-04-03T14:45:00.000Z"); + expect(next("0 0 29 2 *", "Asia/Kolkata", "2026-10-16T00:00:00.000Z")).toBe("2028-02-28T18:30: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(); + // A day-by-day walk takes about 13 ms a search here. + expect(performance.now() - started).toBeLessThan(1_000); + }); +}); + +describe("the tick's check (isOccurrence)", () => { + test("a minute is an occurrence exactly when nextOccurrence from it returns it", () => { + const expressions = ["* * * * *", "30 9 * * 1-5", "0 1-3 * * *", "*/30 * * * *", "45 1 * * *", "15 2 * * *", "30 2 * * *"]; + const zones = ["UTC", "America/New_York", "Australia/Lord_Howe"]; + // Two hours around each change, in the northern and the southern zones. + const windows = [Date.UTC(2026, 2, 8, 6), Date.UTC(2026, 3, 4, 14), Date.UTC(2026, 9, 3, 14, 30), Date.UTC(2026, 10, 1, 5)] + .map((start) => [start, start + 2 * 3_600_000] as const); + for (const zone of zones) { + for (const expr of expressions) { + for (const [start, end] of windows) { + for (let at = start; at < end; at += 60_000) { + const due = nextOccurrence(expr, zone, new Date(at))?.getTime() === at; + expect(isOccurrence(expr, zone, new Date(at + 59_000))).toBe(due); + } + } + } + } + // An expression that names no date, and a zone Intl cannot read, have none. + expect(isOccurrence("0 0 31 2 *", "UTC", new Date("2027-02-28T00:00:00Z"))).toBe(false); + expect(isOccurrence("* * * * *", "Not/AZone", new Date())).toBe(false); + }); + + test("checks one minute however rare the expression, so a tick over many schedules stays short", () => { + const started = performance.now(); + for (let i = 0; i < 2_000; i++) isOccurrence("0 0 31 2 *", "America/New_York", new Date(Date.UTC(2026, 9, 18, 12, i % 60))); + // A forward search runs nine years for this expression (about 13 ms a call). + expect(performance.now() - started).toBeLessThan(2_000); + }); +}); diff --git a/backend/test/schedules-idempotency.test.ts b/backend/test/schedules-idempotency.test.ts index 9987541ec..d34bce7c8 100644 --- a/backend/test/schedules-idempotency.test.ts +++ b/backend/test/schedules-idempotency.test.ts @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"; import { db } from "../src/db/client"; import { commands, runs, scheduleFirings } from "../src/db/schema"; import { subscribeOrg, type OrgChange } from "../src/runs/org-signals"; -import { cronMatches } from "../src/schedules/cron"; +import { cronMatches, nextOccurrence } from "../src/schedules/cron"; import { fireSchedule, firingKey } from "../src/schedules/fire"; import { getScheduleForOrg } from "../src/schedules/repo"; import { fireScheduleForOrg } from "../src/schedules/service"; @@ -165,17 +165,15 @@ describe("timezone-aware cron evaluation", () => { expect(cronMatches("30 6 * * *", instant, "America/Los_Angeles")).toBe(true); }); - test("an unknown timezone falls back to server local time, never throws", () => { + test("a zone the scheduler cannot read matches nothing and names no run, never the server's clock", () => { const instant = new Date("2026-01-15T14:30:00.000Z"); - // Should not throw; result equals the no-timezone (server-local) evaluation. - const bad = cronMatches("* * * * *", instant, "Not/AZone"); - const local = cronMatches("* * * * *", instant); - expect(bad).toBe(local); + expect(cronMatches("* * * * *", instant, "Not/AZone")).toBe(false); + expect(nextOccurrence("* * * * *", "Not/AZone", instant)).toBeNull(); }); }); describe("timezone on the schedules API", () => { - test("create + patch round-trip a valid IANA zone; invalid is rejected", async () => { + test("create + patch round-trip a valid IANA zone; a schedule saved without one runs in UTC; a patch without one keeps it; invalid is rejected", async () => { const s = await createOrgSession("tz-api"); const created = await json("/api/schedules", { @@ -200,16 +198,20 @@ describe("timezone on the schedules API", () => { }); expect(bad.status).toBe(400); - // No timezone → null (server local). - const noneTz = await json("/api/schedules", { - method: "POST", - cookies: s.cookies, - body: { name: "no tz", cron: "0 9 * * *", prompt: "x" }, - }); - expect(noneTz.status).toBe(201); - expect(noneTz.body.timezone).toBeNull(); + // No timezone (an API client, an agent's tool call): UTC, stored and shown, + // never the server's own clock. + for (const timezone of [undefined, null, ""]) { + const noneTz = await json("/api/schedules", { + method: "POST", + cookies: s.cookies, + body: { name: "no tz", cron: "0 9 * * *", prompt: "x", timezone }, + }); + expect(noneTz.status).toBe(201); + expect(noneTz.body.timezone).toBe("UTC"); + } - // PATCH can change and clear the zone. + // PATCH changes the zone. None (null or "": a client still holding the + // schedule from before every schedule named one) leaves it as it is. const patched = await json(`/api/schedules/${created.body.id}`, { method: "PATCH", cookies: s.cookies, @@ -218,12 +220,22 @@ describe("timezone on the schedules API", () => { expect(patched.status).toBe(200); expect(patched.body.timezone).toBe("Europe/London"); - const cleared = await json(`/api/schedules/${created.body.id}`, { + for (const timezone of [null, ""]) { + const kept = await json(`/api/schedules/${created.body.id}`, { + method: "PATCH", + cookies: s.cookies, + body: { timezone, prompt: "evening digest" }, + }); + expect(kept.status).toBe(200); + expect(kept.body.timezone).toBe("Europe/London"); + expect(kept.body.prompt).toBe("evening digest"); + } + const invalid = await json(`/api/schedules/${created.body.id}`, { method: "PATCH", cookies: s.cookies, - body: { timezone: null }, + body: { timezone: "Mars/Olympus" }, }); - expect(cleared.status).toBe(200); - expect(cleared.body.timezone).toBeNull(); + expect(invalid.status).toBe(400); + expect((await getScheduleForOrg(s.orgId, created.body.id))?.timezone).toBe("Europe/London"); }); }); diff --git a/backend/test/schedules.test.ts b/backend/test/schedules.test.ts index 6e64719a5..836023f26 100644 --- a/backend/test/schedules.test.ts +++ b/backend/test/schedules.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import type { ApiRun } from "../src/runs/repo"; import { subscribeOrg, type OrgChange } from "../src/runs/org-signals"; import type { ApiFiring, ApiSchedule } from "../src/schedules/repo"; import { tick } from "../src/schedules/scheduler"; import { acceptRunCommand, type RunCommandIntent } from "../src/commands"; import { fireScheduleWithOutcome, firingKey } from "../src/schedules/fire"; -import { getScheduleForOrg } from "../src/schedules/repo"; +import { getScheduleForOrg, nextRunAt } from "../src/schedules/repo"; import { AUTOMATION_RUN_ORIGIN } from "../src/runs/origin"; import { db } from "../src/db/client"; import { runs, skillRevisions } from "../src/db/schema"; @@ -546,7 +546,7 @@ describe("schedules API", () => { cookies: s.cookies, }); - const now = new Date(2026, 7, 5, 10, 30, 15); // matches "* * * * *" + const now = new Date(Date.UTC(2026, 7, 5, 10, 30, 15)); // matches "* * * * *" await tick(now); const afterFirst = await json( @@ -557,7 +557,7 @@ describe("schedules API", () => { expect(mine.length).toBe(1); // A second tick in the SAME minute must NOT double-fire. - await tick(new Date(2026, 7, 5, 10, 30, 59)); + await tick(new Date(Date.UTC(2026, 7, 5, 10, 30, 59))); const afterSame = await json( `/api/schedules/${created.id}/history`, { cookies: s.cookies }, @@ -565,7 +565,7 @@ describe("schedules API", () => { expect(afterSame.body.firings.filter((f) => f.trigger === "cron").length).toBe(1); // A tick in the NEXT minute fires again. - await tick(new Date(2026, 7, 5, 10, 31, 5)); + await tick(new Date(Date.UTC(2026, 7, 5, 10, 31, 5))); const afterNext = await json( `/api/schedules/${created.id}/history`, { cookies: s.cookies }, @@ -600,7 +600,7 @@ describe("schedules API", () => { if (change.type === "automation") changes.push(change); }); try { - await tick(new Date(2026, 7, 5, 12, 45, 0)); + await tick(new Date(Date.UTC(2026, 7, 5, 12, 45, 0))); expect(changes).toHaveLength(1); expect(changes[0]).toMatchObject({ type: "automation", @@ -620,4 +620,119 @@ describe("schedules API", () => { }); } }); + + test("the tick fires a fixed time once across a fall-back and right after a spring-forward gap", async () => { + const s = await createOrgSession("sched-dst"); + const cronFirings = async (id: string) => + (await json(`/api/schedules/${id}/history`, { cookies: s.cookies })).body.firings + .filter((firing) => firing.trigger === "cron").length; + const enabled = async (cron: string, timezone: string) => { + const created = await createSchedule(s.cookies, { name: `dst ${cron} ${timezone}`, cron, timezone, prompt: "dst", engine: "mock" }); + await json(`/api/schedules/${created.id}`, { method: "PATCH", body: { enabled: true }, cookies: s.cookies }); + return created.id; + }; + // Lord Howe moves half an hour. 2026-04-05: 01:45 LHDT is 14:45Z and the + // fall-back repeats 01:45 at 15:15Z (LHST). 2026-10-04: 02:00 LHST (15:30Z) + // becomes 02:30 LHDT, so 02:15 does not exist and fires at 15:30Z. + const fall = await enabled("45 1 4,5 4 *", "Australia/Lord_Howe"); + const spring = await enabled("15 2 3,4 10 *", "Australia/Lord_Howe"); + // New York 2026-03-08: 02:00-02:59 does not exist; 03:00 EDT is 07:00Z. + const newYork = await enabled("30 2 8 3 *", "America/New_York"); + try { + await tick(new Date("2026-04-04T14:45:10Z")); + await tick(new Date("2026-04-04T15:15:10Z")); + expect(await cronFirings(fall)).toBe(1); + await tick(new Date("2026-10-03T15:30:10Z")); + expect(await cronFirings(spring)).toBe(1); + await tick(new Date("2026-03-08T07:00:10Z")); + expect(await cronFirings(newYork)).toBe(1); + } finally { + for (const id of [fall, spring, newYork]) { + await json(`/api/schedules/${id}`, { method: "PATCH", body: { enabled: false }, cookies: s.cookies }); + } + } + }); + + test("a tick over many schedules whose cron names no date stays one minute's check each", async () => { + const s = await createOrgSession("sched-rare"); + await db.execute(sql` + insert into schedules (org_id, name, cron, timezone, prompt, engine, model, enabled) + select ${s.orgId}, 'never ' || n, '0 0 31 2 *', 'America/New_York', 'never', 'mock', 'm', true + from generate_series(1, 400) as n`); + try { + const started = performance.now(); + await tick(new Date("2026-08-05T10:30:15Z")); + // A nine-year search per schedule takes about 13 ms each, over 5 s here. + expect(performance.now() - started).toBeLessThan(2_000); + } finally { + await db.execute(sql`delete from schedules where org_id = ${s.orgId}`); + } + }); + + test("listing many schedules whose cron names no date stays short", async () => { + const s = await createOrgSession("sched-rare-list"); + await db.execute(sql` + insert into schedules (org_id, name, cron, timezone, prompt, engine, model, enabled) + select ${s.orgId}, 'never ' || n, '0 0 31 2 *', 'America/New_York', 'never', 'mock', 'm', true + from generate_series(1, 400) as n`); + try { + const started = performance.now(); + const listed = await json("/api/automations", { cookies: s.cookies }); + // A nine-year walk per schedule takes about 13 ms each, over 5 s here. + expect(performance.now() - started).toBeLessThan(2_000); + expect(listed.body.automations).toHaveLength(400); + expect(listed.body.automations.every((automation) => automation.next_due_at === null)).toBe(true); + } finally { + await db.execute(sql`delete from schedules where org_id = ${s.orgId}`); + } + }); + + test("a run-now stamped in the next minute, read by a tick late in this one, does not hide this minute's run", async () => { + const s = await createOrgSession("sched-stamp"); + const created = await createSchedule(s.cookies, { name: "Noon", cron: "0 12 * * *", timezone: "UTC", prompt: "noon", engine: "mock" }); + await json(`/api/schedules/${created.id}`, { method: "PATCH", body: { enabled: true }, cookies: s.cookies }); + try { + // The tick took 12:00:59; before it read the schedules, a run-now + // (bots/routes.ts markFired) stamped 12:01:00. + await db.execute(sql`update schedules set last_fired_at = '2026-08-05T12:01:00Z' where id = ${created.id}`); + await tick(new Date("2026-08-05T12:00:59Z")); + const history = await json(`/api/schedules/${created.id}/history`, { cookies: s.cookies }); + expect(history.body.firings.filter((firing) => firing.trigger === "cron")).toHaveLength(1); + const record = (await getScheduleForOrg(s.orgId, created.id))!; + expect(nextRunAt({ ...record, lastFiredAt: new Date("2026-08-05T12:01:00Z") }, new Date("2026-08-05T12:00:59Z"))?.toISOString()) + .toBe("2026-08-05T12:00:00.000Z"); + expect(nextRunAt({ ...record, lastFiredAt: new Date("2026-08-05T12:00:10Z") }, new Date("2026-08-05T12:00:59Z"))?.toISOString()) + .toBe("2026-08-06T12:00:00.000Z"); + } finally { + await json(`/api/schedules/${created.id}`, { method: "PATCH", body: { enabled: false }, cookies: s.cookies }); + } + }); + + test("the API's next run is the minute the tick fires next, on the schedule's clock", async () => { + const s = await createOrgSession("sched-next-run"); + const created = await createSchedule(s.cookies, { name: "Island", cron: "45 1 * * *", timezone: "Australia/Lord_Howe", prompt: "next", engine: "mock" }); + expect(created.next_due_at).toBeNull(); + const enabled = await json(`/api/schedules/${created.id}`, { method: "PATCH", body: { enabled: true }, cookies: s.cookies }); + try { + const next = enabled.body.next_due_at; + expect(next).not.toBeNull(); + const wall = new Intl.DateTimeFormat("en-GB", { timeZone: "Australia/Lord_Howe", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); + expect(wall.format(new Date(next!))).toBe("01:45"); + expect(Date.parse(next!)).toBeGreaterThan(Date.now() - 60_000); + expect(Date.parse(next!)).toBeLessThan(Date.now() + 25 * 3_600_000); + const listed = await json("/api/automations", { cookies: s.cookies }); + expect(listed.body.automations.find((automation) => automation.id === created.id)?.next_due_at).toBe(next); + // The tick fires in exactly the minute the API named, not the one before. + const cronFirings = async () => + (await json(`/api/schedules/${created.id}/history`, { cookies: s.cookies })).body.firings + .filter((firing) => firing.trigger === "cron").length; + await tick(new Date(Date.parse(next!) - 40_000)); + expect(await cronFirings()).toBe(0); + await tick(new Date(Date.parse(next!) + 20_000)); + expect(await cronFirings()).toBe(1); + } finally { + const paused = await json(`/api/schedules/${created.id}`, { method: "PATCH", body: { enabled: false }, cookies: s.cookies }); + expect(paused.body.next_due_at).toBeNull(); + } + }); }); diff --git a/docs-site/docs/api/automations.mdx b/docs-site/docs/api/automations.mdx index 692d832a8..a9e804e65 100644 --- a/docs-site/docs/api/automations.mdx +++ b/docs-site/docs/api/automations.mdx @@ -25,6 +25,13 @@ automation_creator_only`, whose `message` names the creator. The same rule holds for a bot's routines and for an agent's automation tools, where the agent acts as its run's owner. +Every automation runs its `cron` in a named IANA `timezone`. A create that sends +none runs in `UTC` (the web app sends the creator's browser zone); a `PATCH` may +change the zone, and one that names none leaves it as it is. `next_due_at` is the minute the +scheduler fires it next, from the same occurrence rule it fires by, +daylight-saving changes included; it is `null` while the automation is paused or +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). | diff --git a/docs-site/docs/api/bots.mdx b/docs-site/docs/api/bots.mdx index 955308109..110d900f5 100644 --- a/docs-site/docs/api/bots.mdx +++ b/docs-site/docs/api/bots.mdx @@ -30,8 +30,8 @@ Bot names are unique within the org, case-insensitively. | Method | Path | Description | | --- | --- | --- | -| GET | `/api/bots/:id/routines` | List routines attached to the bot. | -| POST | `/api/bots/:id/routines` | Create a routine from `name`, `cron`, `timezone`, `prompt`, and optional `enabled`. Engine, model, repos, and skill come from the bot preset. The bot's creator only; a routine anyone else added earlier answers `403 routine_not_bot_owner` and is never run with the bot's setup (once the bot is archived, its routines run as plain automations of their creator). | +| GET | `/api/bots/:id/routines` | List routines attached to the bot. Each carries its `timezone` and `nextDueAt`, the minute the scheduler fires it next (`null` while paused). | +| POST | `/api/bots/:id/routines` | Create a routine from `name`, `cron`, `timezone` (IANA, `UTC` when omitted), `prompt`, and optional `enabled`. Engine, model, repos, and skill come from the bot preset. The bot's creator only; a routine anyone else added earlier answers `403 routine_not_bot_owner` and is never run with the bot's setup (once the bot is archived, its routines run as plain automations of their creator). | | PATCH | `/api/bots/:id/routines/:routineId` | Update name, cron, timezone, prompt, or enabled state. | | DELETE | `/api/bots/:id/routines/:routineId` | Delete the routine. Returns `204`. | | POST | `/api/bots/:id/routines/:routineId/run-now` | Fire immediately. Returns `202` with `runId` and the updated routine. |