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:
tiammomo 2026-09-18 09:07:25 +08:00 committed by GitHub
parent 408b015d5f
commit 73590a626d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 245 additions and 7 deletions

View File

@ -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

View File

@ -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/)

View File

@ -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>

View File

@ -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"

View File

@ -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",

View File

@ -504,6 +504,7 @@ export interface Translations {
cron: string;
cronPlaceholder: string;
runAt: string;
invalidRunAt: string;
timezone: string;
intervalAmount: string;
intervalUnitSeconds: string;

View File

@ -576,6 +576,7 @@ export const zhCN: Translations = {
cron: "cron 表达式",
cronPlaceholder: "0 9 * * *",
runAt: "运行时间",
invalidRunAt: "所选时区中不存在这个本地时间,请选择其他时间。",
timezone: "时区",
intervalAmount: "每",
intervalUnitSeconds: "秒",

View File

@ -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);

View 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");
});
}

View File

@ -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({});
},
);

View File

@ -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);
},
);

View File

@ -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);
});
});