Add timezone-aware cron schedules (#1232)

* feat: add timezone-aware cron schedules

* chore: normalize schedule timezone property

* fix: reject blank schedule timezone in mcp

* Stabilize terminal resize browser repro

* Fix terminal resize repro row assertions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Daniel Salmerón Amselem
2026-06-01 00:32:45 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 922a93af2f
commit 632c48fde3
17 changed files with 431 additions and 19 deletions
@@ -2328,6 +2328,38 @@ describe("create_schedule MCP tool", () => {
);
});
it("passes timezone through cron create_schedule input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
createStoredSchedule(scheduleInput),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await invokeToolWithParsedInput(tool, {
prompt: "say hello",
cron: "0 9 * * 1-5",
timezone: " America/New_York ",
provider: "codex",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
cadence: {
type: "cron",
expression: "0 9 * * 1-5",
timezone: "America/New_York",
},
}),
);
});
it("still rejects both real every and cron inputs", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
@@ -2352,6 +2384,54 @@ describe("create_schedule MCP tool", () => {
expect(create).not.toHaveBeenCalled();
});
it("rejects create_schedule timezone without cron", async () => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await expect(
invokeToolWithParsedInput(tool, {
prompt: "say hello",
every: "10m",
timezone: "America/New_York",
provider: "codex",
}),
).rejects.toThrow("timezone can only be used with cron");
expect(create).not.toHaveBeenCalled();
});
it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => {
const { agentManager, agentStorage } = createTestDeps();
const create = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { create } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "create_schedule");
await expect(
invokeToolWithParsedInput(tool, {
prompt: "say hello",
cron: "0 9 * * 1-5",
timezone,
provider: "codex",
}),
).rejects.toThrow();
expect(create).not.toHaveBeenCalled();
});
it.each([
{
label: "missing both cadence fields",
@@ -2458,6 +2538,35 @@ describe("update_schedule MCP tool", () => {
});
});
it("passes timezone through cron update_schedule input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await invokeToolWithParsedInput(tool, {
id: "schedule-1",
cron: "0 9 * * 1-5",
timezone: "Europe/Zurich",
});
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: {
type: "cron",
expression: "0 9 * * 1-5",
timezone: "Europe/Zurich",
},
});
});
it("accepts a blank cron field when updating every cadence", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
@@ -2542,6 +2651,52 @@ describe("update_schedule MCP tool", () => {
expect(update).not.toHaveBeenCalled();
});
it("rejects update_schedule timezone without cron", async () => {
const { agentManager, agentStorage } = createTestDeps();
const update = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await expect(
invokeToolWithParsedInput(tool, {
id: "schedule-1",
every: "10m",
timezone: "Europe/Zurich",
}),
).rejects.toThrow("timezone can only be used with cron");
expect(update).not.toHaveBeenCalled();
});
it.each(["", " "])("rejects update_schedule blank timezone %#", async (timezone) => {
const { agentManager, agentStorage } = createTestDeps();
const update = vi.fn();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "update_schedule");
await expect(
invokeToolWithParsedInput(tool, {
id: "schedule-1",
cron: "0 9 * * 1-5",
timezone,
}),
).rejects.toThrow();
expect(update).not.toHaveBeenCalled();
});
it("passes new-agent config and expiry updates", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
+40 -3
View File
@@ -333,6 +333,7 @@ interface ScheduleUpdateToolInput {
id: string;
every?: string;
cron?: string;
timezone?: string;
name?: string | null;
prompt?: string;
maxRuns?: number | null;
@@ -357,18 +358,30 @@ function normalizeScheduleCadenceArg(value: string | undefined): string | undefi
return trimmed;
}
function normalizeScheduleTimeZoneArg(value: string | undefined): string | undefined {
return normalizeScheduleCadenceArg(value);
}
function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined {
const every = normalizeScheduleCadenceArg(input.every);
const cron = normalizeScheduleCadenceArg(input.cron);
const timeZone = normalizeScheduleTimeZoneArg(input.timezone);
if (every !== undefined && cron !== undefined) {
throw new Error("Specify at most one of every or cron");
}
if (timeZone !== undefined && cron === undefined) {
throw new Error("timezone can only be used with cron");
}
if (every !== undefined) {
return { type: "every", everyMs: parseDurationString(every) };
}
if (cron !== undefined) {
return { type: "cron", expression: cron };
return {
type: "cron",
expression: cron,
...(timeZone !== undefined ? { timezone: timeZone } : {}),
};
}
return undefined;
}
@@ -1583,6 +1596,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
prompt: z.string().trim().min(1, "prompt is required"),
every: z.string().optional(),
cron: z.string().optional(),
timezone: z
.string()
.trim()
.min(1)
.optional()
.describe(
"IANA time zone for cron cadence; requires cron. For example: America/New_York.",
),
name: z.string().optional(),
target: z.enum(["self", "new-agent"]).optional(),
provider: AgentProviderEnum.optional().describe(
@@ -1594,18 +1615,22 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
outputSchema: ScheduleSummarySchema.shape,
},
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
async ({ prompt, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
const normalizedEvery = normalizeScheduleCadenceArg(every);
const normalizedCron = normalizeScheduleCadenceArg(cron);
const normalizedTimeZone = normalizeScheduleTimeZoneArg(timezone);
const cadenceCount =
Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined);
if (cadenceCount !== 1) {
throw new Error("Specify exactly one of every or cron");
}
if (normalizedTimeZone !== undefined && normalizedCron === undefined) {
throw new Error("timezone can only be used with cron");
}
const scheduleTarget =
target === "self"
@@ -1643,7 +1668,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cadence:
normalizedEvery !== undefined
? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) }
: { type: "cron" as const, expression: normalizedCron! },
: {
type: "cron" as const,
expression: normalizedCron!,
...(normalizedTimeZone !== undefined ? { timezone: normalizedTimeZone } : {}),
},
target: scheduleTarget,
...(name?.trim() ? { name: name.trim() } : {}),
...(maxRuns === undefined ? {} : { maxRuns }),
@@ -1792,6 +1821,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
id: z.string(),
every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."),
cron: z.string().optional().describe("New cron expression."),
timezone: z
.string()
.trim()
.min(1)
.optional()
.describe(
"IANA time zone for cron cadence; requires cron. For example: America/New_York.",
),
name: z.string().nullable().optional().describe("New name (null to clear)."),
prompt: z.string().trim().min(1).optional().describe("New prompt text."),
maxRuns: z
@@ -20,9 +20,47 @@ describe("schedule cron cadence", () => {
expect(next.toISOString()).toBe("2026-01-05T09:15:00.000Z");
});
test("computes timezone cron matches at the requested wall-clock time", () => {
const winter = computeNextRunAt(
{ type: "cron", expression: "0 9 * * 1-5", timezone: "America/New_York" },
new Date("2026-01-05T13:59:30.000Z"),
);
const summer = computeNextRunAt(
{ type: "cron", expression: "0 9 * * 1-5", timezone: "America/New_York" },
new Date("2026-07-06T12:59:30.000Z"),
);
expect(winter.toISOString()).toBe("2026-01-05T14:00:00.000Z");
expect(summer.toISOString()).toBe("2026-07-06T13:00:00.000Z");
});
test("keeps repeated fall-back wall-clock matches distinct", () => {
const first = computeNextRunAt(
{ type: "cron", expression: "30 1 1 11 *", timezone: "America/New_York" },
new Date("2026-11-01T05:29:30.000Z"),
);
const second = computeNextRunAt(
{ type: "cron", expression: "30 1 1 11 *", timezone: "America/New_York" },
first,
);
expect(first.toISOString()).toBe("2026-11-01T05:30:00.000Z");
expect(second.toISOString()).toBe("2026-11-01T06:30:00.000Z");
});
test("rejects invalid cron expressions", () => {
expect(() => validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" })).toThrow(
"Cron expressions must have 5 fields",
);
});
test("rejects invalid cron time zones", () => {
expect(() =>
validateScheduleCadence({
type: "cron",
expression: "0 9 * * *",
timezone: "Not/AZone",
}),
).toThrow("Invalid cron time zone: Not/AZone");
});
});
+66 -5
View File
@@ -81,6 +81,14 @@ interface ParsedCronExpression {
dayOfWeek: CronFieldMatcher;
}
interface CronDateParts {
minute: number;
hour: number;
dayOfMonth: number;
month: number;
dayOfWeek: number;
}
function parseCronExpression(expression: string): ParsedCronExpression {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
@@ -110,9 +118,65 @@ function startOfNextMinute(date: Date): Date {
);
}
function assertValidTimeZone(timeZone: string): void {
try {
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0));
} catch {
throw new Error(`Invalid cron time zone: ${timeZone}`);
}
}
function createCronDatePartsReader(timeZone: string | undefined): (date: Date) => CronDateParts {
if (timeZone === undefined) {
return (date: Date) => ({
minute: date.getUTCMinutes(),
hour: date.getUTCHours(),
dayOfMonth: date.getUTCDate(),
month: date.getUTCMonth() + 1,
dayOfWeek: date.getUTCDay(),
});
}
assertValidTimeZone(timeZone);
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
return (date: Date) => {
const values: Record<string, string> = {};
for (const part of formatter.formatToParts(date)) {
if (part.type !== "literal") {
values[part.type] = part.value;
}
}
const year = Number.parseInt(values.year, 10);
const month = Number.parseInt(values.month, 10);
const dayOfMonth = Number.parseInt(values.day, 10);
return {
minute: Number.parseInt(values.minute, 10),
hour: Number.parseInt(values.hour, 10),
dayOfMonth,
month,
dayOfWeek: new Date(Date.UTC(year, month - 1, dayOfMonth)).getUTCDay(),
};
};
}
export function validateScheduleCadence(cadence: ScheduleCadence): void {
if (cadence.type === "cron") {
parseCronExpression(cadence.expression);
if (cadence.timezone !== undefined) {
assertValidTimeZone(cadence.timezone);
}
}
}
@@ -122,15 +186,12 @@ export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
}
const cron = parseCronExpression(cadence.expression);
const readDateParts = createCronDatePartsReader(cadence.timezone);
const limit = 366 * 24 * 60;
let cursor = startOfNextMinute(after);
for (let index = 0; index < limit; index += 1) {
const minute = cursor.getUTCMinutes();
const hour = cursor.getUTCHours();
const dayOfMonth = cursor.getUTCDate();
const month = cursor.getUTCMonth() + 1;
const dayOfWeek = cursor.getUTCDay();
const { minute, hour, dayOfMonth, month, dayOfWeek } = readDateParts(cursor);
if (
cron.minute.matches(minute) &&