diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index f5565a88a74..5656ea60339 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -614,6 +614,50 @@ describe("cron cli", () => { ]); }); + it("applies --tz to --at for offset-less datetimes on cron add", async () => { + await runCronCommand([ + "cron", + "add", + "--name", + "tz-at-test", + "--at", + "2026-03-23T23:00:00", + "--tz", + "Europe/Oslo", + "--session", + "isolated", + "--message", + "test", + ]); + + const params = getGatewayCallParams<{ schedule: { kind: string; at: string } }>("cron.add"); + // 2026-03-23 is CET (+01:00), so 23:00 Oslo = 22:00 UTC + expect(params.schedule.kind).toBe("at"); + expect(params.schedule.at).toBe("2026-03-23T22:00:00.000Z"); + }); + + it("does not apply --tz when --at already has an offset", async () => { + await runCronCommand([ + "cron", + "add", + "--name", + "tz-at-offset-test", + "--at", + "2026-03-23T23:00:00+02:00", + "--tz", + "Europe/Oslo", + "--session", + "isolated", + "--message", + "test", + ]); + + const params = getGatewayCallParams<{ schedule: { kind: string; at: string } }>("cron.add"); + // Explicit +02:00 should be honored, not overridden by --tz + expect(params.schedule.kind).toBe("at"); + expect(params.schedule.at).toBe("2026-03-23T21:00:00.000Z"); + }); + it("sets explicit stagger for cron edit", async () => { await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]); diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index e916c459863..2571c52f8ef 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -73,7 +73,10 @@ export function registerCronAddCommand(cron: Command) { .option("--session ", "Session target (main|isolated)") .option("--session-key ", "Session key for job routing (e.g. agent:my-agent:my-session)") .option("--wake ", "Wake mode (now|next-heartbeat)", "now") - .option("--at ", "Run once at time (ISO) or +duration (e.g. 20m)") + .option( + "--at ", + "Run once at time (ISO with offset, or +duration). Use --tz for offset-less datetimes", + ) .option("--every ", "Run every duration (e.g. 10m, 1h)") .option("--cron ", "Cron expression (5-field or 6-field with seconds)") .option("--tz ", "Timezone for cron expressions (IANA)", "") @@ -119,7 +122,9 @@ export function registerCronAddCommand(cron: Command) { throw new Error("--stagger/--exact are only valid with --cron"); } if (at) { - const atIso = parseAt(at); + const tzRaw = + typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined; + const atIso = parseAt(at, tzRaw); if (!atIso) { throw new Error("Invalid --at; use ISO time or duration like 20m"); } diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 41eeccb7036..328d05766e9 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -158,14 +158,13 @@ export function registerCronEditCommand(cron: Command) { if (scheduleChosen > 1) { throw new Error("Choose at most one schedule change"); } - if ( - (requestedStaggerMs !== undefined || typeof opts.tz === "string") && - (opts.at || opts.every) - ) { - throw new Error("--stagger/--exact/--tz are only valid for cron schedules"); + if (requestedStaggerMs !== undefined && (opts.at || opts.every)) { + throw new Error("--stagger/--exact are only valid for cron schedules"); } if (opts.at) { - const atIso = parseAt(String(opts.at)); + const tzRaw = + typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined; + const atIso = parseAt(String(opts.at), tzRaw); if (!atIso) { throw new Error("Invalid --at"); } diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 088c3d426dc..d0576605425 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -89,11 +89,38 @@ export function parseCronStaggerMs(params: { return parsed; } -export function parseAt(input: string): string | null { +/** + * Parse a one-shot `--at` value into an ISO string (UTC). + * + * When `tz` is provided and the input is an offset-less datetime + * (e.g. `2026-03-23T23:00:00`), the datetime is interpreted in + * that IANA timezone instead of UTC. + */ +export function parseAt(input: string, tz?: string): string | null { const raw = input.trim(); if (!raw) { return null; } + + // If a timezone is provided and the input looks like an offset-less ISO datetime, + // resolve it in the given IANA timezone so users get the time they expect. + if (tz) { + const isoNoOffset = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/.test(raw); + if (isoNoOffset) { + try { + // Use Intl to find the UTC offset for the given tz at the specified local time. + // We first parse naively as UTC to get a rough Date, then compute the real offset. + const naiveMs = new Date(`${raw}Z`).getTime(); + if (!Number.isNaN(naiveMs)) { + const offset = getTimezoneOffsetMs(naiveMs, tz); + return new Date(naiveMs - offset).toISOString(); + } + } catch { + // Fall through to default parsing if tz is invalid + } + } + } + const absolute = parseAbsoluteTimeMs(raw); if (absolute !== null) { return new Date(absolute).toISOString(); @@ -105,6 +132,42 @@ export function parseAt(input: string): string | null { return null; } +/** + * Get the UTC offset in milliseconds for a given IANA timezone at a given UTC instant. + * Positive means ahead of UTC (e.g. +3600000 for CET). + */ +function getTimezoneOffsetMs(utcMs: number, tz: string): number { + const d = new Date(utcMs); + // Format parts in the target timezone + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(d); + + const get = (type: string) => { + const part = parts.find((p) => p.type === type); + return Number.parseInt(part?.value ?? "0", 10); + }; + + // Reconstruct the local time as if it were UTC + const localAsUtc = Date.UTC( + get("year"), + get("month") - 1, + get("day"), + get("hour") === 24 ? 0 : get("hour"), + get("minute"), + get("second"), + ); + + return localAsUtc - utcMs; +} + const CRON_ID_PAD = 36; const CRON_NAME_PAD = 24; const CRON_SCHEDULE_PAD = 32;