From 73590a626d5aa1be11eaeee43555237dd2776018 Mon Sep 17 00:00:00 2001 From: tiammomo <26957354+tiammomo@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:07:25 +0800 Subject: [PATCH] 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 --- README.md | 1 + frontend/AGENTS.md | 7 ++ .../app/workspace/scheduled-tasks/page.tsx | 7 +- .../scheduled-task-schedule-input.tsx | 20 +++-- frontend/src/core/i18n/locales/en-US.ts | 2 + frontend/src/core/i18n/locales/types.ts | 1 + frontend/src/core/i18n/locales/zh-CN.ts | 1 + frontend/src/core/scheduled-tasks/cron.ts | 13 +++ .../tests/e2e/scheduled-tasks-dst.spec.ts | 83 +++++++++++++++++++ .../workspace/schedule-dst-gap.dom.test.tsx | 59 +++++++++++++ .../schedule-edit-instant.dom.test.tsx | 30 +++++++ .../scheduled-tasks/valid-local-time.test.ts | 28 +++++++ 12 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 frontend/tests/e2e/scheduled-tasks-dst.spec.ts create mode 100644 frontend/tests/unit/components/workspace/schedule-dst-gap.dom.test.tsx create mode 100644 frontend/tests/unit/core/scheduled-tasks/valid-local-time.test.ts diff --git a/README.md b/README.md index f4691424e..f336b3f36 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 1ee1d215f..dad982307 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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/) diff --git a/frontend/src/app/workspace/scheduled-tasks/page.tsx b/frontend/src/app/workspace/scheduled-tasks/page.tsx index 968ac79d7..7c2a53aab 100644 --- a/frontend/src/app/workspace/scheduled-tasks/page.tsx +++ b/frontend/src/app/workspace/scheduled-tasks/page.tsx @@ -655,6 +655,8 @@ export default function ScheduledTasksPage() { diff --git a/frontend/src/components/workspace/scheduled-task-schedule-input.tsx b/frontend/src/components/workspace/scheduled-task-schedule-input.tsx index 2d0c15709..865952d99 100644 --- a/frontend/src/components/workspace/scheduled-task-schedule-input.tsx +++ b/frontend/src/components/workspace/scheduled-task-schedule-input.tsx @@ -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({ + {invalidOnceTime && ( +

+ {labels.fields.invalidRunAt} +

+ )}
{ + const writes: Record[] = []; + 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); + } + }); + 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"); + }); +} diff --git a/frontend/tests/unit/components/workspace/schedule-dst-gap.dom.test.tsx b/frontend/tests/unit/components/workspace/schedule-dst-gap.dom.test.tsx new file mode 100644 index 000000000..2323db76f --- /dev/null +++ b/frontend/tests/unit/components/workspace/schedule-dst-gap.dom.test.tsx @@ -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( + + emitted.push(value)} + /> + , + ); + const input = container.querySelector( + '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({}); + }, +); diff --git a/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx b/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx index 190e65314..37c90015f 100644 --- a/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/schedule-edit-instant.dom.test.tsx @@ -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( + , + ); + 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); + }, +); diff --git a/frontend/tests/unit/core/scheduled-tasks/valid-local-time.test.ts b/frontend/tests/unit/core/scheduled-tasks/valid-local-time.test.ts new file mode 100644 index 000000000..880b2e116 --- /dev/null +++ b/frontend/tests/unit/core/scheduled-tasks/valid-local-time.test.ts @@ -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); + }); +});