mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +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:
|
||||
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@ -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.
|
||||
|
||||
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
|
||||
|
||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
|
||||
|
||||
@ -655,6 +655,8 @@ export default function ScheduledTasksPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!hasScheduleSpec(editSchedule.schedule_spec))
|
||||
return;
|
||||
const pinned =
|
||||
selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID;
|
||||
updateTask.mutate({
|
||||
@ -667,7 +669,10 @@ export default function ScheduledTasksPage() {
|
||||
timezone: editSchedule.timezone || "UTC",
|
||||
});
|
||||
}}
|
||||
disabled={updateTask.isPending}
|
||||
disabled={
|
||||
updateTask.isPending ||
|
||||
!hasScheduleSpec(editSchedule.schedule_spec)
|
||||
}
|
||||
>
|
||||
{st.edit.submit}
|
||||
</Button>
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
serializeCron,
|
||||
utcToZonedLocalInput,
|
||||
WEEKDAYS,
|
||||
zonedLocalToUtcIso,
|
||||
validZonedLocalToUtcIso,
|
||||
type CronParts,
|
||||
type CronPreset,
|
||||
type IntervalUnit,
|
||||
@ -150,6 +150,11 @@ export function ScheduledTaskScheduleInput({
|
||||
);
|
||||
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
|
||||
// it. This avoids a re-render loop: if the parent passes an inline
|
||||
// onChange (new reference each render), depending on it directly would
|
||||
@ -164,11 +169,7 @@ export function ScheduledTaskScheduleInput({
|
||||
if (scheduleType === "once") {
|
||||
const unchanged =
|
||||
runAtLocal === initialOnce?.local && timezone === initialOnce.timezone;
|
||||
const runAt = unchanged
|
||||
? initialOnce.runAt
|
||||
: runAtLocal
|
||||
? zonedLocalToUtcIso(runAtLocal, timezone)
|
||||
: "";
|
||||
const runAt = unchanged ? initialOnce.runAt : onceRunAt;
|
||||
onChangeRef.current({
|
||||
schedule_type: "once",
|
||||
schedule_spec: runAt ? { run_at: runAt } : {},
|
||||
@ -203,6 +204,7 @@ export function ScheduledTaskScheduleInput({
|
||||
scheduleType,
|
||||
preset,
|
||||
parts,
|
||||
onceRunAt,
|
||||
runAtLocal,
|
||||
timezone,
|
||||
initialOnce,
|
||||
@ -451,6 +453,7 @@ export function ScheduledTaskScheduleInput({
|
||||
value={runAtLocal}
|
||||
onChange={(e) => setRunAtLocal(e.target.value)}
|
||||
aria-label={labels.fields.runAt}
|
||||
aria-invalid={invalidOnceTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -467,6 +470,11 @@ export function ScheduledTaskScheduleInput({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{invalidOnceTime && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{labels.fields.invalidRunAt}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
data-testid="schedule-preview"
|
||||
|
||||
@ -619,6 +619,8 @@ export const enUS: Translations = {
|
||||
cron: "Cron expression",
|
||||
cronPlaceholder: "0 9 * * *",
|
||||
runAt: "Run at",
|
||||
invalidRunAt:
|
||||
"This local time does not exist in the selected timezone. Choose another time.",
|
||||
timezone: "Timezone",
|
||||
intervalAmount: "Every",
|
||||
intervalUnitSeconds: "seconds",
|
||||
|
||||
@ -504,6 +504,7 @@ export interface Translations {
|
||||
cron: string;
|
||||
cronPlaceholder: string;
|
||||
runAt: string;
|
||||
invalidRunAt: string;
|
||||
timezone: string;
|
||||
intervalAmount: string;
|
||||
intervalUnitSeconds: string;
|
||||
|
||||
@ -576,6 +576,7 @@ export const zhCN: Translations = {
|
||||
cron: "cron 表达式",
|
||||
cronPlaceholder: "0 9 * * *",
|
||||
runAt: "运行时间",
|
||||
invalidRunAt: "所选时区中不存在这个本地时间,请选择其他时间。",
|
||||
timezone: "时区",
|
||||
intervalAmount: "每",
|
||||
intervalUnitSeconds: "秒",
|
||||
|
||||
@ -410,6 +410,19 @@ export function utcToZonedLocalInput(iso: string, timezone: string): string {
|
||||
)}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 {
|
||||
const tzParts = formatParts(timezone, 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();
|
||||
}
|
||||
});
|
||||
|
||||
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