fix(frontend): treat invalid cron fields as non-scheduled (#8020)

* fix(frontend): keep invalid cron schedules as custom

* test(frontend): cover inclusive cron bounds

* docs(scheduled-tasks): note cron preset bounds

The form keeps an out-of-range cron as custom; the guide now says which
numeric ranges a preset covers and that saving such an expression returns
422. CHANGELOG entries cover the fix.

* docs(scheduler): clarify invalid cron save validation

---------

Co-authored-by: Nan Gao <thunderggnn@gmail.com>
This commit is contained in:
ChengLong GU 2026-11-14 16:36:25 +08:00 committed by GitHub
parent 4f3a8b790d
commit 35d33bea75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 84 additions and 23 deletions

View File

@ -68,6 +68,15 @@ This section accumulates work toward the **2.3.0** milestone
### Fixed
- **frontend:** Cron presets in the task form now require minute 0-59, hour
0-23 and day of month 1-31, so an out-of-range expression such as
`0 24 * * *` stays a custom schedule and keeps its original text. Previously,
it was parsed as a preset and could be clamped to a different time when
serialized for submission. Creating or duplicating a task with an invalid
expression, or updating its schedule configuration while the expression is
invalid, returns the Gateway's `422`; edits to other fields do not revalidate
an unchanged schedule. ([#8020])
- **gateway:** Creating a thread with checkpoint-owned keys such as `step`,
`parents` or `counters_since_delta_snapshot` in its metadata no longer
leaves the thread broken. Those keys used to land on the thread's first
@ -16415,6 +16424,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7998]: https://github.com/bytedance/deer-flow/issues/7998
[#8006]: https://github.com/bytedance/deer-flow/pull/8006
[#8019]: https://github.com/bytedance/deer-flow/pull/8019
[#8020]: https://github.com/bytedance/deer-flow/pull/8020
[#8043]: https://github.com/bytedance/deer-flow/pull/8043
[#8046]: https://github.com/bytedance/deer-flow/pull/8046
[#8057]: https://github.com/bytedance/deer-flow/pull/8057

View File

@ -33,6 +33,12 @@
### 修复
- **前端:** 任务表单的 Cron 预设现在要求分钟 0-59、小时 0-23、日期 1-31,
超出范围的表达式(例如 `0 24 * * *`)会保持为自定义并保留原始文本。此前它会被识别为预设,
在序列化以供提交时可能被限制到合法范围内的另一个时间。使用非法表达式创建或复制任务,
或在表达式仍非法时更新调度配置,会由 Gateway 返回 `422`;仅修改其他字段不会重新校验
未变化的调度配置。([#8020])
- **网关:** 创建对话时在元数据中带上 `step`、`parents`、`counters_since_delta_snapshot`
等由检查点自身维护的键,不再让该对话损坏。此前这些键会写入对话的首个检查点,错误的 `parents`
会让该对话的历史记录永久返回 500,错误的 `step` 或增量计数器会让之后每一轮对话都失败;
@ -12903,6 +12909,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7998]: https://github.com/bytedance/deer-flow/issues/7998
[#8006]: https://github.com/bytedance/deer-flow/pull/8006
[#8019]: https://github.com/bytedance/deer-flow/pull/8019
[#8020]: https://github.com/bytedance/deer-flow/pull/8020
[#8043]: https://github.com/bytedance/deer-flow/pull/8043
[#8046]: https://github.com/bytedance/deer-flow/pull/8046
[#8064]: https://github.com/bytedance/deer-flow/pull/8064

View File

@ -48,6 +48,8 @@ Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigge
In the task form, switching an hourly, daily, weekly, or monthly schedule to **Custom cron** starts from its current timing, including selected weekdays or day of the month. Switching back to Custom cron after preset edits uses those edits rather than an older expression.
Presets cover minute 0-59, hour 0-23 and day of month 1-31. An expression outside those ranges stays Custom and keeps its original text. Creating or duplicating a task with an invalid expression, or updating its schedule configuration while the expression is invalid, returns `422` from the Gateway. Editing only other fields, such as the title, leaves the schedule configuration out of the update request and does not revalidate the unchanged expression.
## Control proactive IM notifications
Open **Settings → Notifications → IM notifications** to turn scheduled-task

View File

@ -239,6 +239,12 @@ function isStar(field: string): boolean {
return field === "*";
}
function cronNumber(field: string, min: number, max: number): number | null {
if (!/^\d+$/.test(field)) return null;
const value = Number(field);
return value >= min && value <= max ? value : null;
}
function isSimpleDowList(field: string): boolean {
return field.split(",").every((tok) => /^[0-7]$/.test(tok));
}
@ -258,26 +264,23 @@ export function parseCron(cron: string): {
const domF = fields[2]!;
const monF = fields[3]!;
const dowF = fields[4]!;
const numMinute = /^\d+$/.test(mF);
const numHour = /^\d+$/.test(hF);
const numDom = /^\d+$/.test(domF);
const minute = cronNumber(mF, 0, 59);
const hour = cronNumber(hF, 0, 23);
const dayOfMonth = cronNumber(domF, 1, 31);
const stars = isStar(domF) && isStar(monF);
// hourly: "M * * * *"
if (numMinute && isStar(hF) && stars && isStar(dowF)) {
return { preset: "hourly", parts: { minute: Number(mF) } };
if (minute !== null && isStar(hF) && stars && isStar(dowF)) {
return { preset: "hourly", parts: { minute } };
}
// daily: "M H * * *"
if (numMinute && numHour && stars && isStar(dowF)) {
return {
preset: "daily",
parts: { minute: Number(mF), hour: Number(hF) },
};
if (minute !== null && hour !== null && stars && isStar(dowF)) {
return { preset: "daily", parts: { minute, hour } };
}
// weekly: "M H * * DOW" (simple comma list of single digits only)
if (
numMinute &&
numHour &&
minute !== null &&
hour !== null &&
isStar(domF) &&
isStar(monF) &&
!isStar(dowF) &&
@ -291,15 +294,21 @@ export function parseCron(cron: string): {
if (ordered.length > 0) {
return {
preset: "weekly",
parts: { minute: Number(mF), hour: Number(hF), weekdays: ordered },
parts: { minute, hour, weekdays: ordered },
};
}
}
// monthly: "M H DOM * *"
if (numMinute && numHour && numDom && isStar(monF) && isStar(dowF)) {
if (
minute !== null &&
hour !== null &&
dayOfMonth !== null &&
isStar(monF) &&
isStar(dowF)
) {
return {
preset: "monthly",
parts: { minute: Number(mF), hour: Number(hF), dayOfMonth: Number(domF) },
parts: { minute, hour, dayOfMonth },
};
}
return { preset: "custom", parts: { raw: expr } };
@ -395,13 +404,14 @@ function parseDisplayCron(cron: string): {
}
const fields = cron.split(/\s+/);
const [m, h, dom, mon, dow] = fields;
if (fields.length !== 5 || !m || !h || !dow) {
return parsed;
}
const minute = cronNumber(m, 0, 59);
const hour = cronNumber(h, 0, 23);
if (
fields.length !== 5 ||
!m ||
!h ||
!dow ||
!/^\d+$/.test(m) ||
!/^\d+$/.test(h) ||
minute === null ||
hour === null ||
dom !== "*" ||
mon !== "*" ||
!/^[0-7](-[0-7])?(,[0-7](-[0-7])?)*$/.test(dow)
@ -422,8 +432,8 @@ function parseDisplayCron(cron: string): {
return {
preset: "weekly",
parts: {
minute: Number(m),
hour: Number(h),
minute,
hour,
weekdays: orderedWeekdays([...days]),
},
};

View File

@ -132,6 +132,21 @@ describe("parseCron", () => {
expect(parseCron("garbage").preset).toBe("custom");
expect(parseCron("garbage").parts.raw).toBe("garbage");
});
test.each([
"60 * * * *",
"99 9 * * *",
"0 24 * * *",
"0 9 0 * *",
"0 9 32 * *",
])("keeps the out-of-range cron %j as custom", (cron) => {
expect(parseCron(cron)).toEqual({ preset: "custom", parts: { raw: cron } });
});
test("accepts inclusive upper bounds for numeric fields", () => {
expect(parseCron("59 23 * * *").preset).toBe("daily");
expect(parseCron("0 9 31 * *").preset).toBe("monthly");
});
});
describe("describeSchedule", () => {
@ -601,6 +616,13 @@ describe("describeTaskSchedule", () => {
).toBe("工作日 08:30 (Asia/Shanghai)");
});
test("max valid weekdays still read as weekdays", () => {
expect(describeTaskSchedule(cronTask("59 23 * * 1-5"), "en-US")).toEqual({
text: "Weekdays at 23:59 (Asia/Shanghai)",
raw: "59 23 * * 1-5",
});
});
test("daily reuses the preset wording", () => {
expect(describeTaskSchedule(cronTask("0 9 * * *"), "en-US").text).toBe(
"Every day at 09:00 (Asia/Shanghai)",
@ -610,6 +632,16 @@ describe("describeTaskSchedule", () => {
);
});
test.each(["60 9 * * 1-5", "0 24 * * 1-5"])(
"does not describe the out-of-range cron %j as a preset",
(cron) => {
expect(describeTaskSchedule(cronTask(cron), "en-US")).toEqual({
text: "Custom schedule (Asia/Shanghai)",
raw: cron,
});
},
);
test("a custom cron never shows the expression in text", () => {
const { text, raw } = describeTaskSchedule(
cronTask("*/15 9-17 * * 1-5"),