mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-01 17:12:30 +00:00
fix: centralize cron schedule number coercion
This commit is contained in:
@@ -623,6 +623,74 @@ describe("normalizeCronJobCreate", () => {
|
||||
expect(validateCronAddParams(normalized)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes string every schedule numbers for create jobs", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "every-string",
|
||||
schedule: {
|
||||
everyMs: "60000",
|
||||
anchorMs: "123.9",
|
||||
},
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "hi",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule).toEqual({
|
||||
kind: "every",
|
||||
everyMs: 60_000,
|
||||
anchorMs: 123,
|
||||
});
|
||||
expect(validateCronAddParams(normalized)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes string every schedule numbers for patches", () => {
|
||||
const normalized = normalizeCronJobPatch({
|
||||
schedule: {
|
||||
kind: "every",
|
||||
everyMs: "60000",
|
||||
anchorMs: "123.9",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule).toEqual({
|
||||
kind: "every",
|
||||
everyMs: 60_000,
|
||||
anchorMs: 123,
|
||||
});
|
||||
expect(validateCronUpdateParams({ id: "job", patch: normalized })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps invalid every schedule numbers invalid for validation", () => {
|
||||
const zeroEvery = normalizeCronJobCreate({
|
||||
name: "every-zero",
|
||||
schedule: {
|
||||
kind: "every",
|
||||
everyMs: "0",
|
||||
},
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "hi",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(validateCronAddParams(zeroEvery)).toBe(false);
|
||||
|
||||
const negativeAnchor = normalizeCronJobPatch({
|
||||
schedule: {
|
||||
kind: "every",
|
||||
everyMs: "60000",
|
||||
anchorMs: "-1",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(validateCronUpdateParams({ id: "job", patch: negativeAnchor })).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces sessionTarget and wakeMode casing", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "casing",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
parseOptionalField,
|
||||
} from "./delivery-field-schemas.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||
import { inferLegacyName } from "./service/normalize.js";
|
||||
import {
|
||||
assertSafeCronSessionTargetId,
|
||||
@@ -73,6 +74,8 @@ function coerceSchedule(schedule: UnknownRecord) {
|
||||
const exprRaw = normalizeOptionalString(schedule.expr) ?? "";
|
||||
const legacyCronRaw = normalizeOptionalString(schedule.cron) ?? "";
|
||||
const normalizedExpr = exprRaw || legacyCronRaw;
|
||||
const everyMs = coerceFiniteScheduleNumber(schedule.everyMs);
|
||||
const anchorMs = coerceFiniteScheduleNumber(schedule.anchorMs);
|
||||
const atMsRaw = schedule.atMs;
|
||||
const atRaw = schedule.at;
|
||||
const atString = normalizeOptionalString(atRaw) ?? "";
|
||||
@@ -94,7 +97,7 @@ function coerceSchedule(schedule: UnknownRecord) {
|
||||
typeof schedule.atMs === "string"
|
||||
) {
|
||||
next.kind = "at";
|
||||
} else if (typeof schedule.everyMs === "number") {
|
||||
} else if (everyMs !== undefined) {
|
||||
next.kind = "every";
|
||||
} else if (normalizedExpr) {
|
||||
next.kind = "cron";
|
||||
@@ -115,6 +118,13 @@ function coerceSchedule(schedule: UnknownRecord) {
|
||||
} else if ("expr" in next) {
|
||||
delete next.expr;
|
||||
}
|
||||
|
||||
if (everyMs !== undefined && everyMs >= 1) {
|
||||
next.everyMs = Math.floor(everyMs);
|
||||
}
|
||||
if (anchorMs !== undefined && anchorMs >= 0) {
|
||||
next.anchorMs = Math.floor(anchorMs);
|
||||
}
|
||||
if ("cron" in next) {
|
||||
delete next.cron;
|
||||
}
|
||||
|
||||
23
src/cron/schedule-identity.test.ts
Normal file
23
src/cron/schedule-identity.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cronSchedulingInputsEqual, tryCronScheduleIdentity } from "./schedule-identity.js";
|
||||
|
||||
describe("tryCronScheduleIdentity", () => {
|
||||
it("normalizes numeric schedule strings like execution does", () => {
|
||||
const numeric = tryCronScheduleIdentity({
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000, anchorMs: 123 },
|
||||
});
|
||||
const stringNumeric = tryCronScheduleIdentity({
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: "60000", anchorMs: "123" },
|
||||
});
|
||||
|
||||
expect(stringNumeric).toBe(numeric);
|
||||
expect(
|
||||
cronSchedulingInputsEqual(
|
||||
{ schedule: { kind: "every", everyMs: 60_000, anchorMs: 123 } },
|
||||
{ schedule: { kind: "every", everyMs: "60000", anchorMs: "123" } },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asFiniteNumber } from "../shared/number-coercion.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
@@ -7,8 +7,7 @@ function readString(record: Record<string, unknown>, key: string): string | unde
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
return asFiniteNumber(value);
|
||||
return coerceFiniteScheduleNumber(record[key]);
|
||||
}
|
||||
|
||||
function schedulePayloadFromRecord(
|
||||
|
||||
5
src/cron/schedule-number.ts
Normal file
5
src/cron/schedule-number.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { parseStrictFiniteNumber } from "../shared/number-coercion.js";
|
||||
|
||||
export function coerceFiniteScheduleNumber(value: unknown): number | undefined {
|
||||
return parseStrictFiniteNumber(value);
|
||||
}
|
||||
@@ -250,6 +250,8 @@ describe("coerceFiniteScheduleNumber", () => {
|
||||
it("returns undefined for invalid inputs", () => {
|
||||
expect(coerceFiniteScheduleNumber("")).toBeUndefined();
|
||||
expect(coerceFiniteScheduleNumber("abc")).toBeUndefined();
|
||||
expect(coerceFiniteScheduleNumber("60000ms")).toBeUndefined();
|
||||
expect(coerceFiniteScheduleNumber("0x10")).toBeUndefined();
|
||||
expect(coerceFiniteScheduleNumber(Number.NaN)).toBeUndefined();
|
||||
expect(coerceFiniteScheduleNumber(Infinity)).toBeUndefined();
|
||||
expect(coerceFiniteScheduleNumber(null)).toBeUndefined();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Cron } from "croner";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||
import type { CronSchedule } from "./types.js";
|
||||
|
||||
export { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||
|
||||
const CRON_EVAL_CACHE_MAX = 512;
|
||||
const cronEvalCache = new Map<string, Cron>();
|
||||
|
||||
@@ -50,21 +53,6 @@ function resolveCronFromSchedule(schedule: {
|
||||
return resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
|
||||
}
|
||||
|
||||
export function coerceFiniteScheduleNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
|
||||
if (schedule.kind === "at") {
|
||||
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.
|
||||
|
||||
Reference in New Issue
Block a user