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:
co-authored by
Mohamed Boudra
parent
922a93af2f
commit
632c48fde3
@@ -13,6 +13,7 @@ import {
|
||||
export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
@@ -34,6 +35,7 @@ export async function runCreateCommand(
|
||||
prompt,
|
||||
every: options.every,
|
||||
cron: options.cron,
|
||||
timezone: options.timezone,
|
||||
name: options.name,
|
||||
target: options.target,
|
||||
provider: options.provider,
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createScheduleCommand(): Command {
|
||||
.argument("<prompt>", "Prompt to run on the schedule")
|
||||
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
|
||||
.option("--cron <expr>", "Cron cadence expression")
|
||||
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
|
||||
.option("--name <name>", "Optional schedule name")
|
||||
.option("--target <self|new-agent|agent-id>", "Run target")
|
||||
.option(
|
||||
@@ -82,6 +83,7 @@ export function createScheduleCommand(): Command {
|
||||
.argument("<id>", "Schedule ID")
|
||||
.option("--every <duration>", "Switch to fixed interval cadence (for example: 5m, 1h)")
|
||||
.option("--cron <expr>", "Switch to cron cadence expression")
|
||||
.option("--timezone <iana>", "IANA time zone for cron cadence (requires --cron)")
|
||||
.option("--name <name>", "Rename the schedule (empty string clears the name)")
|
||||
.option("--prompt <text>", "Replace the schedule prompt")
|
||||
.option(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OutputSchema } from "../../output/index.js";
|
||||
import { formatTarget, type ScheduleRow } from "./shared.js";
|
||||
import { formatCadence, formatTarget, type ScheduleRow } from "./shared.js";
|
||||
import type { ScheduleRecord, ScheduleRunRecord } from "./types.js";
|
||||
|
||||
export const scheduleSchema: OutputSchema<ScheduleRow> = {
|
||||
@@ -73,7 +73,7 @@ export function createScheduleInspectRows(schedule: ScheduleRecord): ScheduleIns
|
||||
key: "Cadence",
|
||||
value:
|
||||
schedule.cadence.type === "cron"
|
||||
? `cron:${schedule.cadence.expression}`
|
||||
? formatCadence(schedule.cadence)
|
||||
: `every:${schedule.cadence.everyMs}ms`,
|
||||
},
|
||||
{ key: "Target", value: formatTarget(schedule.target) },
|
||||
|
||||
@@ -88,6 +88,19 @@ describe("parseScheduleCreateInput first-run timing", () => {
|
||||
expect(input.runOnCreate).toBe(true);
|
||||
});
|
||||
|
||||
test("--cron with --timezone stores a timezone-aware cadence", () => {
|
||||
const input = parseScheduleCreateInput({
|
||||
...baseCron,
|
||||
timezone: " America/New_York ",
|
||||
});
|
||||
|
||||
expect(input.cadence).toEqual({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
});
|
||||
|
||||
test("--every with --run-now is rejected as redundant", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow(
|
||||
expect.objectContaining({
|
||||
@@ -105,6 +118,15 @@ describe("parseScheduleCreateInput first-run timing", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("--timezone without --cron is rejected", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseOptions, timezone: "Europe/Zurich" })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "INVALID_TIME_ZONE",
|
||||
message: "--timezone can only be used with --cron",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScheduleUpdateInput", () => {
|
||||
@@ -151,12 +173,31 @@ describe("parseScheduleUpdateInput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses --cron cadence with --timezone", () => {
|
||||
expect(
|
||||
parseScheduleUpdateInput({
|
||||
id: "abc",
|
||||
cron: "30 9 * * *",
|
||||
timezone: "Europe/Zurich",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "abc",
|
||||
cadence: { type: "cron", expression: "30 9 * * *", timezone: "Europe/Zurich" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects passing both --every and --cron", () => {
|
||||
expect(() => parseScheduleUpdateInput({ id: "abc", every: "5m", cron: "0 9 * * *" })).toThrow(
|
||||
expect.objectContaining({ code: "INVALID_CADENCE" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects --timezone without --cron", () => {
|
||||
expect(() => parseScheduleUpdateInput({ id: "abc", timezone: "Europe/Zurich" })).toThrow(
|
||||
expect.objectContaining({ code: "INVALID_TIME_ZONE" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("parses provider/model shorthand and explicit mode", () => {
|
||||
expect(
|
||||
parseScheduleUpdateInput({
|
||||
|
||||
@@ -49,7 +49,8 @@ export function toScheduleCommandError(code: string, action: string, error: unkn
|
||||
|
||||
export function formatCadence(cadence: ScheduleCadence): string {
|
||||
if (cadence.type === "cron") {
|
||||
return `cron:${cadence.expression}`;
|
||||
const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : "";
|
||||
return `cron:${cadence.expression}${timezoneSuffix}`;
|
||||
}
|
||||
return `every:${formatDurationMs(cadence.everyMs)}`;
|
||||
}
|
||||
@@ -129,6 +130,7 @@ export function parseScheduleCreateInput(options: {
|
||||
prompt: string;
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
@@ -147,7 +149,7 @@ export function parseScheduleCreateInput(options: {
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const cadence = parseCadenceFromFlags(options.every, options.cron);
|
||||
const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
|
||||
if (!cadence) {
|
||||
throw {
|
||||
code: "INVALID_CADENCE",
|
||||
@@ -232,6 +234,7 @@ export interface ScheduleUpdateOptionsInput {
|
||||
id: string;
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
provider?: string;
|
||||
@@ -253,7 +256,7 @@ export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): U
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const cadence = parseCadenceFromFlags(options.every, options.cron);
|
||||
const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
|
||||
const newAgentConfig = buildNewAgentConfigPatch(options);
|
||||
const maxRuns = parseUpdateMaxRuns(options);
|
||||
const expiresAt = parseUpdateExpiresAt(options);
|
||||
@@ -288,6 +291,7 @@ export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): U
|
||||
function parseCadenceFromFlags(
|
||||
every: string | undefined,
|
||||
cron: string | undefined,
|
||||
timezone: string | undefined,
|
||||
): ScheduleCadence | undefined {
|
||||
if (every !== undefined && cron !== undefined) {
|
||||
throw {
|
||||
@@ -295,15 +299,40 @@ function parseCadenceFromFlags(
|
||||
message: "Specify at most one of --every or --cron",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const trimmedTimeZone = parseTimeZoneFlag(timezone);
|
||||
if (trimmedTimeZone !== undefined && cron === undefined) {
|
||||
throw {
|
||||
code: "INVALID_TIME_ZONE",
|
||||
message: "--timezone can only be used with --cron",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
if (every !== undefined) {
|
||||
return { type: "every", everyMs: parseDuration(every) };
|
||||
}
|
||||
if (cron !== undefined) {
|
||||
return { type: "cron", expression: cron.trim() };
|
||||
return {
|
||||
type: "cron",
|
||||
expression: cron.trim(),
|
||||
...(trimmedTimeZone ? { timezone: trimmedTimeZone } : {}),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTimeZoneFlag(timeZone: string | undefined): string | undefined {
|
||||
if (timeZone === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = timeZone.trim();
|
||||
if (!trimmed) {
|
||||
throw {
|
||||
code: "INVALID_TIME_ZONE",
|
||||
message: "--timezone cannot be empty",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseUpdateMaxRuns(options: ScheduleUpdateOptionsInput): number | null | undefined {
|
||||
if (options.maxRuns !== undefined && options.clearMaxRuns) {
|
||||
throw {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ScheduleCadence =
|
||||
| {
|
||||
type: "cron";
|
||||
expression: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type ScheduleTarget =
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
export interface ScheduleUpdateOptions extends ScheduleCommandOptions {
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
provider?: string;
|
||||
@@ -36,6 +37,7 @@ export async function runUpdateCommand(
|
||||
id,
|
||||
every: options.every,
|
||||
cron: options.cron,
|
||||
timezone: options.timezone,
|
||||
name: options.name,
|
||||
prompt: options.prompt,
|
||||
provider: options.provider,
|
||||
|
||||
Reference in New Issue
Block a user