mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 02:41:07 +00:00
fix: land cron tz one-shot handling and prerelease config warnings (#53224) (thanks @RolfHegr)
This commit is contained in:
@@ -658,6 +658,27 @@ describe("cron cli", () => {
|
||||
expect(params.schedule.at).toBe("2026-03-23T21:00:00.000Z");
|
||||
});
|
||||
|
||||
it("applies --tz to --at correctly across DST boundaries on cron add", async () => {
|
||||
await runCronCommand([
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"tz-at-dst-test",
|
||||
"--at",
|
||||
"2026-03-29T01:30:00",
|
||||
"--tz",
|
||||
"Europe/Oslo",
|
||||
"--session",
|
||||
"isolated",
|
||||
"--message",
|
||||
"test",
|
||||
]);
|
||||
|
||||
const params = getGatewayCallParams<{ schedule: { kind: string; at: string } }>("cron.add");
|
||||
expect(params.schedule.kind).toBe("at");
|
||||
expect(params.schedule.at).toBe("2026-03-29T00:30:00.000Z");
|
||||
});
|
||||
|
||||
it("sets explicit stagger for cron edit", async () => {
|
||||
await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]);
|
||||
|
||||
@@ -685,6 +706,24 @@ describe("cron cli", () => {
|
||||
await expectCronEditWithScheduleLookupExit({ kind: "every", everyMs: 60_000 }, ["--exact"]);
|
||||
});
|
||||
|
||||
it("applies --tz to --at for offset-less datetimes on cron edit", async () => {
|
||||
const patch = await runCronEditAndGetPatch([
|
||||
"--at",
|
||||
"2026-03-23T23:00:00",
|
||||
"--tz",
|
||||
"Europe/Oslo",
|
||||
]);
|
||||
|
||||
expect(patch?.patch?.schedule).toEqual({
|
||||
kind: "at",
|
||||
at: "2026-03-23T22:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects --tz with --every on cron edit", async () => {
|
||||
await expectCronCommandExit(["cron", "edit", "job-1", "--every", "10m", "--tz", "UTC"]);
|
||||
});
|
||||
|
||||
it("patches failure alert settings on cron edit", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
|
||||
@@ -158,6 +158,9 @@ export function registerCronEditCommand(cron: Command) {
|
||||
if (scheduleChosen > 1) {
|
||||
throw new Error("Choose at most one schedule change");
|
||||
}
|
||||
if (typeof opts.tz === "string" && opts.every) {
|
||||
throw new Error("--tz is only valid with --cron or offset-less --at");
|
||||
}
|
||||
if (requestedStaggerMs !== undefined && (opts.at || opts.every)) {
|
||||
throw new Error("--stagger/--exact are only valid for cron schedules");
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export function parseCronStaggerMs(params: {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/;
|
||||
|
||||
/**
|
||||
* Parse a one-shot `--at` value into an ISO string (UTC).
|
||||
*
|
||||
@@ -104,20 +106,10 @@ export function parseAt(input: string, tz?: string): string | 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
|
||||
}
|
||||
if (tz && OFFSETLESS_ISO_DATETIME_RE.test(raw)) {
|
||||
const resolved = parseOffsetlessAtInTimezone(raw, tz);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +124,26 @@ export function parseAt(input: string, tz?: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOffsetlessAtInTimezone(raw: string, tz: string): string | null {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
|
||||
|
||||
const naiveMs = new Date(`${raw}Z`).getTime();
|
||||
if (Number.isNaN(naiveMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-check the offset at the first candidate instant so DST boundaries
|
||||
// land on the intended wall-clock time instead of drifting by one hour.
|
||||
const firstOffsetMs = getTimezoneOffsetMs(naiveMs, tz);
|
||||
const candidateMs = naiveMs - firstOffsetMs;
|
||||
const finalOffsetMs = getTimezoneOffsetMs(candidateMs, tz);
|
||||
return new Date(naiveMs - finalOffsetMs).toISOString();
|
||||
} catch {
|
||||
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).
|
||||
@@ -160,7 +172,7 @@ function getTimezoneOffsetMs(utcMs: number, tz: string): number {
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
get("hour") === 24 ? 0 : get("hour"),
|
||||
get("hour"),
|
||||
get("minute"),
|
||||
get("second"),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user