mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-21 12:06:18 +00:00
fix(scheduled-tasks): reject nonexistent local execution times (#5348)
* fix(scheduled-tasks): reject nonexistent local execution times Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> * test(scheduled-tasks): align valid-time fixture with instant preservation Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> --------- Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
408b015d5f
commit
73590a626d
@ -1782,6 +1782,7 @@ Editing a one-time task's title or prompt preserves its original execution time,
|
|||||||
Current MVP capabilities:
|
Current MVP capabilities:
|
||||||
|
|
||||||
- Manage tasks at `/workspace/scheduled-tasks`
|
- Manage tasks at `/workspace/scheduled-tasks`
|
||||||
|
- One-time task forms reject local times skipped by daylight-saving transitions; select another time before creating or saving the task.
|
||||||
- Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run
|
- Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run
|
||||||
- Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected
|
- Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected
|
||||||
- Duplicate an existing task into the create form as an editable draft without copying its run history
|
- Duplicate an existing task into the create form as an editable draft without copying its run history
|
||||||
|
|||||||
@ -127,6 +127,13 @@ the standalone server from `frontend/` with `node --env-file=.env
|
|||||||
|
|
||||||
To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in `DEER_FLOW_DEV_ALLOWED_ORIGINS` (comma-separated; a full URL is reduced to its host). It feeds Next's `allowedDevOrigins`, which gates `/_next/*`, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.
|
To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in `DEER_FLOW_DEV_ALLOWED_ORIGINS` (comma-separated; a full URL is reduced to its host). It feeds Next's `allowedDevOrigins`, which gates `/_next/*`, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.
|
||||||
|
|
||||||
|
One-time schedule input uses `validZonedLocalToUtcIso` to reject wall times that
|
||||||
|
do not round-trip in the selected timezone. Invalid input emits an empty spec and
|
||||||
|
localized inline feedback; both create and edit must block submission. Keep this
|
||||||
|
UI validation separate from the API payload. Preserve the original instant when
|
||||||
|
wall time and timezone match the mounted snapshot; validate changed inputs, and
|
||||||
|
restore the exact original timestamp when those edits are reverted.
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
|
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
|
||||||
|
|||||||
@ -655,6 +655,8 @@ export default function ScheduledTasksPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!hasScheduleSpec(editSchedule.schedule_spec))
|
||||||
|
return;
|
||||||
const pinned =
|
const pinned =
|
||||||
selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID;
|
selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID;
|
||||||
updateTask.mutate({
|
updateTask.mutate({
|
||||||
@ -667,7 +669,10 @@ export default function ScheduledTasksPage() {
|
|||||||
timezone: editSchedule.timezone || "UTC",
|
timezone: editSchedule.timezone || "UTC",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
disabled={updateTask.isPending}
|
disabled={
|
||||||
|
updateTask.isPending ||
|
||||||
|
!hasScheduleSpec(editSchedule.schedule_spec)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{st.edit.submit}
|
{st.edit.submit}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import {
|
|||||||
serializeCron,
|
serializeCron,
|
||||||
utcToZonedLocalInput,
|
utcToZonedLocalInput,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
zonedLocalToUtcIso,
|
validZonedLocalToUtcIso,
|
||||||
type CronParts,
|
type CronParts,
|
||||||
type CronPreset,
|
type CronPreset,
|
||||||
type IntervalUnit,
|
type IntervalUnit,
|
||||||
@ -150,6 +150,11 @@ export function ScheduledTaskScheduleInput({
|
|||||||
);
|
);
|
||||||
const [intervalEdited, setIntervalEdited] = useState(false);
|
const [intervalEdited, setIntervalEdited] = useState(false);
|
||||||
|
|
||||||
|
const onceRunAt = runAtLocal
|
||||||
|
? validZonedLocalToUtcIso(runAtLocal, timezone)
|
||||||
|
: null;
|
||||||
|
const invalidOnceTime = scheduleType === "once" && !!runAtLocal && !onceRunAt;
|
||||||
|
|
||||||
// Hold the latest onChange in a ref so the effect below does not depend on
|
// Hold the latest onChange in a ref so the effect below does not depend on
|
||||||
// it. This avoids a re-render loop: if the parent passes an inline
|
// it. This avoids a re-render loop: if the parent passes an inline
|
||||||
// onChange (new reference each render), depending on it directly would
|
// onChange (new reference each render), depending on it directly would
|
||||||
@ -164,11 +169,7 @@ export function ScheduledTaskScheduleInput({
|
|||||||
if (scheduleType === "once") {
|
if (scheduleType === "once") {
|
||||||
const unchanged =
|
const unchanged =
|
||||||
runAtLocal === initialOnce?.local && timezone === initialOnce.timezone;
|
runAtLocal === initialOnce?.local && timezone === initialOnce.timezone;
|
||||||
const runAt = unchanged
|
const runAt = unchanged ? initialOnce.runAt : onceRunAt;
|
||||||
? initialOnce.runAt
|
|
||||||
: runAtLocal
|
|
||||||
? zonedLocalToUtcIso(runAtLocal, timezone)
|
|
||||||
: "";
|
|
||||||
onChangeRef.current({
|
onChangeRef.current({
|
||||||
schedule_type: "once",
|
schedule_type: "once",
|
||||||
schedule_spec: runAt ? { run_at: runAt } : {},
|
schedule_spec: runAt ? { run_at: runAt } : {},
|
||||||
@ -203,6 +204,7 @@ export function ScheduledTaskScheduleInput({
|
|||||||
scheduleType,
|
scheduleType,
|
||||||
preset,
|
preset,
|
||||||
parts,
|
parts,
|
||||||
|
onceRunAt,
|
||||||
runAtLocal,
|
runAtLocal,
|
||||||
timezone,
|
timezone,
|
||||||
initialOnce,
|
initialOnce,
|
||||||
@ -451,6 +453,7 @@ export function ScheduledTaskScheduleInput({
|
|||||||
value={runAtLocal}
|
value={runAtLocal}
|
||||||
onChange={(e) => setRunAtLocal(e.target.value)}
|
onChange={(e) => setRunAtLocal(e.target.value)}
|
||||||
aria-label={labels.fields.runAt}
|
aria-label={labels.fields.runAt}
|
||||||
|
aria-invalid={invalidOnceTime}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -467,6 +470,11 @@ export function ScheduledTaskScheduleInput({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{invalidOnceTime && (
|
||||||
|
<p role="alert" className="text-destructive text-sm">
|
||||||
|
{labels.fields.invalidRunAt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="text-muted-foreground text-sm"
|
className="text-muted-foreground text-sm"
|
||||||
data-testid="schedule-preview"
|
data-testid="schedule-preview"
|
||||||
|
|||||||
@ -619,6 +619,8 @@ export const enUS: Translations = {
|
|||||||
cron: "Cron expression",
|
cron: "Cron expression",
|
||||||
cronPlaceholder: "0 9 * * *",
|
cronPlaceholder: "0 9 * * *",
|
||||||
runAt: "Run at",
|
runAt: "Run at",
|
||||||
|
invalidRunAt:
|
||||||
|
"This local time does not exist in the selected timezone. Choose another time.",
|
||||||
timezone: "Timezone",
|
timezone: "Timezone",
|
||||||
intervalAmount: "Every",
|
intervalAmount: "Every",
|
||||||
intervalUnitSeconds: "seconds",
|
intervalUnitSeconds: "seconds",
|
||||||
|
|||||||
@ -504,6 +504,7 @@ export interface Translations {
|
|||||||
cron: string;
|
cron: string;
|
||||||
cronPlaceholder: string;
|
cronPlaceholder: string;
|
||||||
runAt: string;
|
runAt: string;
|
||||||
|
invalidRunAt: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
intervalAmount: string;
|
intervalAmount: string;
|
||||||
intervalUnitSeconds: string;
|
intervalUnitSeconds: string;
|
||||||
|
|||||||
@ -576,6 +576,7 @@ export const zhCN: Translations = {
|
|||||||
cron: "cron 表达式",
|
cron: "cron 表达式",
|
||||||
cronPlaceholder: "0 9 * * *",
|
cronPlaceholder: "0 9 * * *",
|
||||||
runAt: "运行时间",
|
runAt: "运行时间",
|
||||||
|
invalidRunAt: "所选时区中不存在这个本地时间,请选择其他时间。",
|
||||||
timezone: "时区",
|
timezone: "时区",
|
||||||
intervalAmount: "每",
|
intervalAmount: "每",
|
||||||
intervalUnitSeconds: "秒",
|
intervalUnitSeconds: "秒",
|
||||||
|
|||||||
@ -410,6 +410,19 @@ export function utcToZonedLocalInput(iso: string, timezone: string): string {
|
|||||||
)}T${pad2(local.getUTCHours())}:${pad2(local.getUTCMinutes())}`;
|
)}T${pad2(local.getUTCHours())}:${pad2(local.getUTCMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate minute-precision YYYY-MM-DDTHH:mm input; invalid or skipped wall times return null. */
|
||||||
|
export function validZonedLocalToUtcIso(
|
||||||
|
localValue: string,
|
||||||
|
timezone: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const iso = zonedLocalToUtcIso(localValue, timezone);
|
||||||
|
return utcToZonedLocalInput(iso, timezone) === localValue ? iso : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function tzOffsetMs(timezone: string, date: Date): number {
|
function tzOffsetMs(timezone: string, date: Date): number {
|
||||||
const tzParts = formatParts(timezone, date);
|
const tzParts = formatParts(timezone, date);
|
||||||
const utcParts = formatParts("UTC", date);
|
const utcParts = formatParts("UTC", date);
|
||||||
|
|||||||
83
frontend/tests/e2e/scheduled-tasks-dst.spec.ts
Normal file
83
frontend/tests/e2e/scheduled-tasks-dst.spec.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||||
|
|
||||||
|
for (const mode of ["create", "edit"] as const) {
|
||||||
|
test(`${mode} blocks a nonexistent local time and submits the corrected instant`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const writes: Record<string, unknown>[] = [];
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (
|
||||||
|
request.method() === (mode === "create" ? "POST" : "PATCH") &&
|
||||||
|
new URL(request.url()).pathname.includes("/api/scheduled-tasks")
|
||||||
|
) {
|
||||||
|
writes.push(request.postDataJSON() as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [],
|
||||||
|
scheduledTasks:
|
||||||
|
mode === "create"
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: "dst-task",
|
||||||
|
thread_id: null,
|
||||||
|
title: "DST task",
|
||||||
|
prompt: "Summarize",
|
||||||
|
schedule_type: "once",
|
||||||
|
schedule_spec: { run_at: "2027-03-14T06:30:00Z" },
|
||||||
|
timezone: "America/New_York",
|
||||||
|
status: "enabled",
|
||||||
|
next_run_at: "2027-03-14T06:30:00Z",
|
||||||
|
last_run_at: null,
|
||||||
|
last_run_id: null,
|
||||||
|
last_error: null,
|
||||||
|
run_count: 0,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await page.goto("/workspace/scheduled-tasks");
|
||||||
|
const form = page.getByTestId(
|
||||||
|
mode === "create"
|
||||||
|
? "scheduled-task-create-form"
|
||||||
|
: "scheduled-task-detail",
|
||||||
|
);
|
||||||
|
if (mode === "create") {
|
||||||
|
await form.getByRole("button", { name: "One-time" }).click();
|
||||||
|
await form.getByPlaceholder("Task title").fill("DST task");
|
||||||
|
await form.getByPlaceholder("Prompt").fill("Summarize");
|
||||||
|
await form.getByTestId("schedule-timezone").click();
|
||||||
|
await page
|
||||||
|
.getByRole("option", { name: "America/New_York", exact: true })
|
||||||
|
.click();
|
||||||
|
} else {
|
||||||
|
await form.getByRole("button", { name: "Edit", exact: true }).click();
|
||||||
|
}
|
||||||
|
const submit = form.getByRole("button", {
|
||||||
|
name: mode === "create" ? "Create" : "Save edit",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await form.getByLabel("Run at").fill("2027-03-14T02:30");
|
||||||
|
await expect(form.getByRole("alert")).toContainText(
|
||||||
|
"This local time does not exist",
|
||||||
|
);
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
expect(writes).toHaveLength(0);
|
||||||
|
await form.getByLabel("Run at").fill("");
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
await form.getByLabel("Run at").fill("2027-03-14T03:30");
|
||||||
|
await expect(form.getByRole("alert")).toHaveCount(0);
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
await submit.click();
|
||||||
|
await expect.poll(() => writes.length).toBe(1);
|
||||||
|
expect(writes[0]).toMatchObject({
|
||||||
|
schedule_spec: { run_at: "2027-03-14T07:30:00+00:00" },
|
||||||
|
timezone: "America/New_York",
|
||||||
|
});
|
||||||
|
expect(writes[0]).not.toHaveProperty("invalidRunAt");
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { afterEach, expect, test } from "@rstest/core";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ScheduledTaskScheduleInput,
|
||||||
|
type ScheduleValue,
|
||||||
|
} from "@/components/workspace/scheduled-task-schedule-input";
|
||||||
|
import { I18nProvider } from "@/core/i18n/context";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
document.cookie = "locale=; max-age=0; path=/";
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["en-US", "This local time does not exist"],
|
||||||
|
["zh-CN", "所选时区中不存在这个本地时间"],
|
||||||
|
] as const)(
|
||||||
|
"invalid wall time clears the spec and recovers in %s",
|
||||||
|
(locale, message) => {
|
||||||
|
document.cookie = `locale=${locale}; path=/`;
|
||||||
|
const emitted: ScheduleValue[] = [];
|
||||||
|
const { container } = render(
|
||||||
|
<I18nProvider initialLocale={locale}>
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
initial={{
|
||||||
|
schedule_type: "once",
|
||||||
|
schedule_spec: { run_at: "2027-03-14T06:30:00+00:00" },
|
||||||
|
timezone: "America/New_York",
|
||||||
|
}}
|
||||||
|
onChange={(value) => emitted.push(value)}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
const input = container.querySelector<HTMLInputElement>(
|
||||||
|
'input[type="datetime-local"]',
|
||||||
|
)!;
|
||||||
|
expect(input.value).toBe("2027-03-14T01:30");
|
||||||
|
expect(emitted.at(-1)?.schedule_spec.run_at).toBe(
|
||||||
|
"2027-03-14T06:30:00+00:00",
|
||||||
|
);
|
||||||
|
fireEvent.change(input, { target: { value: "2027-03-14T02:30" } });
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain(message);
|
||||||
|
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(emitted.at(-1)?.schedule_spec).toEqual({});
|
||||||
|
expect(Object.keys(emitted.at(-1)!)).toEqual([
|
||||||
|
"schedule_type",
|
||||||
|
"schedule_spec",
|
||||||
|
"timezone",
|
||||||
|
]);
|
||||||
|
fireEvent.change(input, { target: { value: "2027-03-14T03:30" } });
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
expect(emitted.at(-1)?.schedule_spec.run_at).toBe(
|
||||||
|
"2027-03-14T07:30:00+00:00",
|
||||||
|
);
|
||||||
|
fireEvent.change(input, { target: { value: "" } });
|
||||||
|
expect(emitted.at(-1)?.schedule_spec).toEqual({});
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -231,3 +231,33 @@ test("empty timezone uses a non-UTC browser zone without changing the instant",
|
|||||||
detectedZone.mockRestore();
|
detectedZone.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["2026-11-01T06:30:00Z", "2026-11-01T01:30"],
|
||||||
|
["2027-06-01T12:30:45.123Z", "2027-06-01T08:30"],
|
||||||
|
])(
|
||||||
|
"rejects a gap edit and restores the exact original instant: %s",
|
||||||
|
(runAt, local) => {
|
||||||
|
const initial = once(runAt);
|
||||||
|
const onChange = rs.fn();
|
||||||
|
const ui = render(
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
initial={initial}
|
||||||
|
onChange={onChange}
|
||||||
|
scheduleTypeLocked
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = ui.getByLabelText("Run at");
|
||||||
|
fireEvent.change(input, { target: { value: "2027-03-14T02:30" } });
|
||||||
|
expect(ui.getByRole("alert").textContent).toContain(
|
||||||
|
"This local time does not exist",
|
||||||
|
);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
...initial,
|
||||||
|
schedule_spec: {},
|
||||||
|
});
|
||||||
|
fireEvent.change(input, { target: { value: local } });
|
||||||
|
expect(ui.queryByRole("alert")).toBeNull();
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(initial);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, test } from "@rstest/core";
|
||||||
|
|
||||||
|
import { validZonedLocalToUtcIso } from "@/core/scheduled-tasks/cron";
|
||||||
|
|
||||||
|
describe("one-time local time validation", () => {
|
||||||
|
test.each([
|
||||||
|
["America/New_York", "2027-03-14T02:30"],
|
||||||
|
["Australia/Lord_Howe", "2027-10-03T02:15"],
|
||||||
|
["Pacific/Apia", "2011-12-30T12:00"],
|
||||||
|
["UTC", "2027-02-30T12:00"],
|
||||||
|
["Invalid/Timezone", "2027-06-01T12:00"],
|
||||||
|
["UTC", ""],
|
||||||
|
])("rejects %s %s without throwing", (timezone, local) => {
|
||||||
|
expect(validZonedLocalToUtcIso(local, timezone)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["America/New_York", "2027-03-14T01:30", "2027-03-14T06:30:00+00:00"],
|
||||||
|
["America/New_York", "2027-03-14T03:30", "2027-03-14T07:30:00+00:00"],
|
||||||
|
["America/New_York", "2026-11-01T01:30", "2026-11-01T05:30:00+00:00"],
|
||||||
|
["Australia/Lord_Howe", "2027-10-03T01:45", "2027-10-02T15:15:00+00:00"],
|
||||||
|
["Australia/Lord_Howe", "2027-10-03T02:45", "2027-10-02T15:45:00+00:00"],
|
||||||
|
["Asia/Shanghai", "2027-03-14T02:30", "2027-03-13T18:30:00+00:00"],
|
||||||
|
["UTC", "2027-01-01T00:00", "2027-01-01T00:00:00+00:00"],
|
||||||
|
])("accepts %s %s", (timezone, local, expected) => {
|
||||||
|
expect(validZonedLocalToUtcIso(local, timezone)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user