fix(cron): make --tz work with --at for one-shot jobs

Previously, `--at` with an offset-less ISO datetime (e.g. `2026-03-23T23:00:00`)
was always interpreted as UTC, even when `--tz` was provided. This caused one-shot
jobs to fire at the wrong time.

Changes:
- `parseAt()` now accepts an optional `tz` parameter
- When `--tz` is provided with `--at`, offset-less datetimes are interpreted in
  that IANA timezone using Intl.DateTimeFormat
- Datetimes with explicit offsets (e.g. `+01:00`, `Z`) are unaffected
- Removed the guard in cron-edit that blocked `--tz` with `--at`
- Updated `--at` help text to mention `--tz` support
- Added 2 tests verifying timezone resolution and offset preservation
This commit is contained in:
Rolfy
2026-03-24 00:12:13 +01:00
committed by Peter Steinberger
parent 8f9799307b
commit 9aac5582d6
4 changed files with 120 additions and 9 deletions

View File

@@ -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"]);

View File

@@ -73,7 +73,10 @@ export function registerCronAddCommand(cron: Command) {
.option("--session <target>", "Session target (main|isolated)")
.option("--session-key <key>", "Session key for job routing (e.g. agent:my-agent:my-session)")
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", "now")
.option("--at <when>", "Run once at time (ISO) or +duration (e.g. 20m)")
.option(
"--at <when>",
"Run once at time (ISO with offset, or +duration). Use --tz for offset-less datetimes",
)
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
.option("--cron <expr>", "Cron expression (5-field or 6-field with seconds)")
.option("--tz <iana>", "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");
}

View File

@@ -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");
}

View File

@@ -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;