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

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