mirror of https://github.com/openclaw/openclaw.git
fix: reject nonexistent zoned cron at-times
This commit is contained in:
parent
69a317995d
commit
0857447a5d
|
|
@ -696,6 +696,23 @@ describe("cron cli", () => {
|
|||
expect(params.schedule.at).toBe("2026-03-29T00:30:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects nonexistent DST gap wall-clock times on cron add", async () => {
|
||||
await expectCronCommandExit([
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"tz-at-gap-test",
|
||||
"--at",
|
||||
"2026-03-29T02:30:00",
|
||||
"--tz",
|
||||
"Europe/Oslo",
|
||||
"--session",
|
||||
"isolated",
|
||||
"--message",
|
||||
"test",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets explicit stagger for cron edit", async () => {
|
||||
await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -109,10 +109,7 @@ 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 && isOffsetlessIsoDateTime(raw)) {
|
||||
const resolved = parseOffsetlessIsoDateTimeInTimeZone(raw, tz);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
return parseOffsetlessIsoDateTimeInTimeZone(raw, tz);
|
||||
}
|
||||
|
||||
const absolute = parseAbsoluteTimeMs(raw);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ describe("parseOffsetlessIsoDateTimeInTimeZone", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("returns null for nonexistent DST gap wall-clock times", () => {
|
||||
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T02:30:00", "Europe/Oslo")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for invalid input", () => {
|
||||
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00+02:00", "Europe/Oslo")).toBe(
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/;
|
||||
const OFFSETLESS_ISO_DATETIME_PARTS_RE =
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/;
|
||||
|
||||
type OffsetlessIsoDateTimeParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
millisecond: number;
|
||||
};
|
||||
|
||||
export function isOffsetlessIsoDateTime(raw: string): boolean {
|
||||
return OFFSETLESS_ISO_DATETIME_RE.test(raw);
|
||||
}
|
||||
|
||||
export function parseOffsetlessIsoDateTimeInTimeZone(raw: string, timeZone: string): string | null {
|
||||
const expectedParts = parseOffsetlessIsoDateTimeParts(raw);
|
||||
if (!expectedParts) {
|
||||
return null;
|
||||
}
|
||||
if (!isOffsetlessIsoDateTime(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -21,12 +37,67 @@ export function parseOffsetlessIsoDateTimeInTimeZone(raw: string, timeZone: stri
|
|||
const firstOffsetMs = getTimeZoneOffsetMs(naiveMs, timeZone);
|
||||
const candidateMs = naiveMs - firstOffsetMs;
|
||||
const finalOffsetMs = getTimeZoneOffsetMs(candidateMs, timeZone);
|
||||
return new Date(naiveMs - finalOffsetMs).toISOString();
|
||||
const resolvedMs = naiveMs - finalOffsetMs;
|
||||
if (!matchesOffsetlessIsoDateTimeParts(resolvedMs, timeZone, expectedParts)) {
|
||||
return null;
|
||||
}
|
||||
return new Date(resolvedMs).toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOffsetlessIsoDateTimeParts(raw: string): OffsetlessIsoDateTimeParts | null {
|
||||
const match = OFFSETLESS_ISO_DATETIME_PARTS_RE.exec(raw);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const fractionalMs = (match[7] ?? "").padEnd(3, "0").slice(0, 3);
|
||||
return {
|
||||
year: Number.parseInt(match[1] ?? "0", 10),
|
||||
month: Number.parseInt(match[2] ?? "0", 10),
|
||||
day: Number.parseInt(match[3] ?? "0", 10),
|
||||
hour: Number.parseInt(match[4] ?? "0", 10),
|
||||
minute: Number.parseInt(match[5] ?? "0", 10),
|
||||
second: Number.parseInt(match[6] ?? "0", 10),
|
||||
millisecond: Number.parseInt(fractionalMs || "0", 10),
|
||||
};
|
||||
}
|
||||
|
||||
function matchesOffsetlessIsoDateTimeParts(
|
||||
utcMs: number,
|
||||
timeZone: string,
|
||||
expected: OffsetlessIsoDateTimeParts,
|
||||
): boolean {
|
||||
const utcDate = new Date(utcMs);
|
||||
if (utcDate.getUTCMilliseconds() !== expected.millisecond) {
|
||||
return false;
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(utcDate);
|
||||
const getNumericPart = (type: string) => {
|
||||
const part = parts.find((candidate) => candidate.type === type);
|
||||
return Number.parseInt(part?.value ?? "0", 10);
|
||||
};
|
||||
return (
|
||||
getNumericPart("year") === expected.year &&
|
||||
getNumericPart("month") === expected.month &&
|
||||
getNumericPart("day") === expected.day &&
|
||||
getNumericPart("hour") === expected.hour &&
|
||||
getNumericPart("minute") === expected.minute &&
|
||||
getNumericPart("second") === expected.second
|
||||
);
|
||||
}
|
||||
|
||||
function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number {
|
||||
const utcDate = new Date(utcMs);
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
|
|
@ -38,6 +109,7 @@ function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number {
|
|||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(utcDate);
|
||||
|
||||
const getNumericPart = (type: string) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue