mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
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:
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user