fix(scheduled-tasks): preserve unchanged one-time execution instants (#5330)

* fix(scheduled-tasks): preserve unchanged one-time execution instants

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

* fix(scheduled-tasks): reset edit state before task remounts

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

* test(scheduled-tasks): exercise timezone fallback on UTC runners

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

---------

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
This commit is contained in:
tiammomo 2026-09-18 08:06:31 +08:00 committed by GitHub
parent 796ca28f55
commit 6d725f1ccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 404 additions and 33 deletions

View File

@ -1777,6 +1777,8 @@ Deleting a project moves its entire shelf to trash in the same step.
DeerFlow now includes a first-class scheduled-task MVP in the workspace.
Editing a one-time task's title or prompt preserves its original execution time, including seconds and the selected occurrence during a daylight-saving clock rollback. Changing its date, time, or timezone recalculates the execution time. Switching tasks while editing loads the selected task's own title, prompt, and schedule.
Current MVP capabilities:
- Manage tasks at `/workspace/scheduled-tasks`

View File

@ -95,6 +95,8 @@ do not use HTML `maxLength`, which counts UTF-16 code units instead.
- **Path alias**: `@/*` maps to `src/*`.
- **Components**: `ui/` and `ai-elements/` are generated from registries (Shadcn, MagicUI, React Bits, Vercel AI SDK) — don't manually edit these.
Single-run schedule edits retain the mounted task's original `run_at` while its wall time and timezone match. The parent echoes edits through `initial`; retain a stable snapshot and reset the parent draft during render before remounting with a task key when switching tasks. Use the resolved timezone consistently for the snapshot and displayed wall time. Component and scheduled-task E2E tests cover DST folds and timestamp precision.
## Environment
Scheduled-task interval forms preserve the initial `every_seconds` on mount,

View File

@ -145,6 +145,7 @@ export default function ScheduledTasksPage() {
>("all");
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [editTaskId, setEditTaskId] = useState<string | undefined>(undefined);
const [editTitle, setEditTitle] = useState("");
const [editPrompt, setEditPrompt] = useState("");
const [editAssistantId, setEditAssistantId] = useState(DEFAULT_ASSISTANT_ID);
@ -264,35 +265,35 @@ export default function ScheduledTasksPage() {
}
}, [filteredData, selectedTaskId]);
useEffect(() => {
// Reset before children commit so the keyed input captures this task.
// Same-id refetches retain the in-progress draft.
if (editTaskId !== selectedTask?.id) {
setEditTaskId(selectedTask?.id);
if (!selectedTask) {
setEditing(false);
return;
} else {
setEditTitle(selectedTask.title);
setEditPrompt(selectedTask.prompt);
setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID);
const spec = selectedTask.schedule_spec as {
cron?: string;
run_at?: string;
every_seconds?: number;
};
setEditSchedule({
schedule_type: selectedTask.schedule_type,
schedule_spec: {
cron: typeof spec.cron === "string" ? spec.cron : undefined,
run_at: typeof spec.run_at === "string" ? spec.run_at : undefined,
every_seconds:
typeof spec.every_seconds === "number"
? spec.every_seconds
: undefined,
},
timezone: selectedTask.timezone || "UTC",
});
}
setEditTitle(selectedTask.title);
setEditPrompt(selectedTask.prompt);
setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID);
const spec = selectedTask.schedule_spec as {
cron?: string;
run_at?: string;
every_seconds?: number;
};
setEditSchedule({
schedule_type: selectedTask.schedule_type,
schedule_spec: {
cron: typeof spec.cron === "string" ? spec.cron : undefined,
run_at: typeof spec.run_at === "string" ? spec.run_at : undefined,
every_seconds:
typeof spec.every_seconds === "number"
? spec.every_seconds
: undefined,
},
timezone: selectedTask.timezone || "UTC",
});
// Depend on id only so a background refetch (same task, new object reference)
// does not wipe edits in progress.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTask?.id]);
}
return (
<WorkspaceContainer>

View File

@ -118,16 +118,27 @@ export function ScheduledTaskScheduleInput({
const [parts, setParts] = useState<CronParts>(
() => parseCron(initial.schedule_spec.cron ?? "0 9 * * *").parts,
);
const [timezone, setTimezone] = useState<string>(
() => initial.timezone || detectBrowserTimezone(),
);
const [runAtLocal, setRunAtLocal] = useState<string>(
initial.schedule_type === "once" && initial.schedule_spec.run_at
? utcToZonedLocalInput(
initial.schedule_spec.run_at,
initial.timezone || "UTC",
)
? utcToZonedLocalInput(initial.schedule_spec.run_at, timezone)
: "",
);
const [timezone, setTimezone] = useState<string>(
initial.timezone || detectBrowserTimezone(),
// Minute-precision wall time cannot retain seconds or identify the later
// occurrence of a repeated DST time. Keep the mounted task's original
// instant while its schedule fields match, even if the parent echoes edits
// back through initial. Task switches remount this component with a key.
const [initialOnce] = useState(() =>
initial.schedule_type === "once" && initial.schedule_spec.run_at
? {
runAt: initial.schedule_spec.run_at,
local: runAtLocal,
timezone,
}
: null,
);
const initialInterval = parseInitialInterval(initial.schedule_spec);
const [intervalAmount, setIntervalAmount] = useState(initialInterval.amount);
@ -151,7 +162,13 @@ export function ScheduledTaskScheduleInput({
// value always matches what the user sees in the preview.
useEffect(() => {
if (scheduleType === "once") {
const runAt = runAtLocal ? zonedLocalToUtcIso(runAtLocal, timezone) : "";
const unchanged =
runAtLocal === initialOnce?.local && timezone === initialOnce.timezone;
const runAt = unchanged
? initialOnce.runAt
: runAtLocal
? zonedLocalToUtcIso(runAtLocal, timezone)
: "";
onChangeRef.current({
schedule_type: "once",
schedule_spec: runAt ? { run_at: runAt } : {},
@ -188,6 +205,7 @@ export function ScheduledTaskScheduleInput({
parts,
runAtLocal,
timezone,
initialOnce,
intervalAmount,
intervalUnit,
intervalEdited,

View File

@ -458,3 +458,118 @@ test("edit omits assistant_id when the agent is unchanged", async ({
expect(patchBody).toMatchObject({ title: "Renamed digest" });
expect(patchBody).not.toHaveProperty("assistant_id");
});
for (const runAt of ["2026-11-01T06:30:00Z", "2027-06-01T12:30:45Z"]) {
test(`editing title and prompt preserves one-time instant ${runAt}`, async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "task-instant",
thread_id: null,
context_mode: "fresh_thread_per_run",
title: "Original title",
prompt: "Original prompt",
schedule_type: "once",
schedule_spec: { run_at: runAt },
timezone: "America/New_York",
status: "enabled",
next_run_at: runAt,
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00Z",
updated_at: "2026-07-01T00:00:00Z",
},
],
});
await page.goto("/workspace/scheduled-tasks");
const detail = page.getByTestId("scheduled-task-detail");
await detail.getByRole("button", { name: "Edit", exact: true }).click();
await detail.getByPlaceholder("Edit title").fill("Renamed task");
await detail.getByPlaceholder("Edit prompt").fill("Updated prompt");
const submitted = page.waitForRequest(
(request) =>
request.method() === "PATCH" &&
new URL(request.url()).pathname === "/api/scheduled-tasks/task-instant",
);
await detail
.getByRole("button", { name: "Save edit", exact: true })
.click();
const request = await submitted;
expect(request.postDataJSON()).toMatchObject({
title: "Renamed task",
prompt: "Updated prompt",
schedule_spec: { run_at: runAt },
timezone: "America/New_York",
});
});
}
test("switching tasks during editing saves only the selected task's schedule", async ({
page,
}) => {
const tasks = [
{
id: "switch-a",
title: "Task A",
prompt: "Prompt A",
timezone: "America/New_York",
runAt: "2026-03-01T05:00:00+00:00",
},
{
id: "switch-b",
title: "Task B",
prompt: "Prompt B",
timezone: "Asia/Shanghai",
runAt: "2027-06-01T12:30:45+00:00",
},
];
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: tasks.map((task) => ({
id: task.id,
title: task.title,
prompt: task.prompt,
timezone: task.timezone,
thread_id: null,
context_mode: "fresh_thread_per_run",
schedule_type: "once",
schedule_spec: { run_at: task.runAt },
status: "enabled",
next_run_at: task.runAt,
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00Z",
updated_at: "2026-07-01T00:00:00Z",
})),
});
await page.goto("/workspace/scheduled-tasks");
await page.getByTestId("scheduled-task-item-switch-a").click();
const detail = page.getByTestId("scheduled-task-detail");
await detail.getByRole("button", { name: "Edit", exact: true }).click();
await detail.getByPlaceholder("Edit title").fill("Unsaved A");
await page.getByTestId("scheduled-task-item-switch-b").click();
await expect(detail.getByPlaceholder("Edit title")).toHaveValue("Task B");
await expect(detail.getByPlaceholder("Edit prompt")).toHaveValue("Prompt B");
await expect(detail.getByLabel("Run at")).toHaveValue("2027-06-01T20:30");
await detail.getByLabel("Run at").fill("2027-06-01T21:30");
await detail.getByLabel("Run at").fill("2027-06-01T20:30");
const submitted = page.waitForRequest(
(request) =>
request.method() === "PATCH" &&
new URL(request.url()).pathname === "/api/scheduled-tasks/switch-b",
);
await detail.getByRole("button", { name: "Save edit", exact: true }).click();
expect((await submitted).postDataJSON()).toMatchObject({
title: "Task B",
prompt: "Prompt B",
timezone: "Asia/Shanghai",
schedule_spec: { run_at: tasks[1]!.runAt },
});
});

View File

@ -0,0 +1,233 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { useState, type ReactNode } from "react";
import {
ScheduledTaskScheduleInput,
type ScheduleValue,
} from "@/components/workspace/scheduled-task-schedule-input";
import { enUS } from "@/core/i18n/locales/en-US";
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({ locale: "en-US", t: enUS }),
}));
// Exercise schedule state with native selects; the page E2E uses real Radix UI.
rs.mock("@/components/ui/select", () => ({
Select: ({
value,
onValueChange,
children,
}: {
value: string;
onValueChange: (value: string) => void;
children: ReactNode;
}) => (
<select
value={value}
onChange={(event) => onValueChange(event.target.value)}
>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: ReactNode }) => <>{children}</>,
SelectItem: ({ value, children }: { value: string; children: ReactNode }) => (
<option value={value}>{children}</option>
),
}));
afterEach(cleanup);
function once(runAt: string, timezone = "America/New_York"): ScheduleValue {
return { schedule_type: "once", schedule_spec: { run_at: runAt }, timezone };
}
test.each([
"2026-11-01T06:30:00Z",
"2027-06-01T12:30:45Z",
"2027-06-01T12:30:45.123Z",
"2027-06-01T08:30:00-04:00",
])("preserves the original one-time timestamp on mount: %s", (runAt) => {
const initial = once(runAt);
const onChange = rs.fn();
render(
<ScheduledTaskScheduleInput
initial={initial}
onChange={onChange}
scheduleTypeLocked
/>,
);
expect(onChange).toHaveBeenLastCalledWith(initial);
});
test("parent feedback and title-only rerenders retain the original instant", () => {
const original = once("2026-11-01T06:30:00Z");
function Editor() {
const [schedule, setSchedule] = useState(original);
const [title, setTitle] = useState("Original");
return (
<>
<input
aria-label="Title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<ScheduledTaskScheduleInput
initial={schedule}
onChange={(next) => setSchedule(next)}
scheduleTypeLocked
/>
<output>{schedule.schedule_spec.run_at}</output>
</>
);
}
const ui = render(<Editor />);
fireEvent.change(ui.getByLabelText("Title"), {
target: { value: "Renamed" },
});
expect(ui.getByRole("status").textContent).toBe(
original.schedule_spec.run_at,
);
fireEvent.change(ui.getByLabelText("Run at"), {
target: { value: "2026-11-01T03:30" },
});
expect(ui.getByRole("status").textContent).toBe("2026-11-01T08:30:00+00:00");
// Returning to the original fields must use the original snapshot, not the
// latest value passed back through initial by the parent.
fireEvent.change(ui.getByLabelText("Run at"), {
target: { value: "2026-11-01T01:30" },
});
expect(ui.getByRole("status").textContent).toBe(
original.schedule_spec.run_at,
);
});
test("changing timezone recomputes the instant and reverting restores seconds", () => {
const initial = once("2027-06-01T12:30:45Z");
const onChange = rs.fn();
const ui = render(
<ScheduledTaskScheduleInput
initial={initial}
onChange={onChange}
scheduleTypeLocked
/>,
);
fireEvent.change(ui.getByRole("combobox"), {
target: { value: "Asia/Shanghai" },
});
expect(onChange).toHaveBeenLastCalledWith(
once("2027-06-01T00:30:00+00:00", "Asia/Shanghai"),
);
fireEvent.change(ui.getByRole("combobox"), {
target: { value: "America/New_York" },
});
expect(onChange).toHaveBeenLastCalledWith(initial);
});
test("clearing the date removes run_at", () => {
const onChange = rs.fn();
const ui = render(
<ScheduledTaskScheduleInput
initial={once("2027-06-01T12:30:45Z")}
onChange={onChange}
scheduleTypeLocked
/>,
);
fireEvent.change(ui.getByLabelText("Run at"), { target: { value: "" } });
expect(onChange).toHaveBeenLastCalledWith({
schedule_type: "once",
schedule_spec: {},
timezone: "America/New_York",
});
});
test("empty create form converts a newly entered date", () => {
const onChange = rs.fn();
const ui = render(
<ScheduledTaskScheduleInput
initial={{
schedule_type: "once",
schedule_spec: {},
timezone: "Asia/Shanghai",
}}
onChange={onChange}
/>,
);
expect(onChange).toHaveBeenLastCalledWith({
schedule_type: "once",
schedule_spec: {},
timezone: "Asia/Shanghai",
});
fireEvent.change(ui.getByLabelText("Run at"), {
target: { value: "2027-06-01T09:30" },
});
expect(onChange).toHaveBeenLastCalledWith(
once("2027-06-01T01:30:00+00:00", "Asia/Shanghai"),
);
});
test("switching schedule type preserves cron behavior and an unchanged once value", () => {
const initial = once("2027-06-01T12:30:45Z");
const onChange = rs.fn();
const ui = render(
<ScheduledTaskScheduleInput initial={initial} onChange={onChange} />,
);
fireEvent.click(ui.getByRole("button", { name: "Recurring" }));
expect(onChange).toHaveBeenLastCalledWith({
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "America/New_York",
});
fireEvent.click(ui.getByRole("button", { name: "One-time" }));
expect(onChange).toHaveBeenLastCalledWith(initial);
});
test("a keyed task switch captures the next task's instant", () => {
const onChange = rs.fn();
const ui = render(
<ScheduledTaskScheduleInput
key="first"
initial={once("2026-11-01T06:30:00Z")}
onChange={onChange}
scheduleTypeLocked
/>,
);
const next = once("2027-06-01T12:30:45Z");
ui.rerender(
<ScheduledTaskScheduleInput
key="second"
initial={next}
onChange={onChange}
scheduleTypeLocked
/>,
);
expect(onChange).toHaveBeenLastCalledWith(next);
});
test("empty timezone uses a non-UTC browser zone without changing the instant", () => {
const timezone = "Asia/Shanghai";
const browserOptions = Intl.DateTimeFormat().resolvedOptions();
const detectedZone = rs
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
.mockReturnValue({ ...browserOptions, timeZone: timezone });
try {
const initial = once("2026-11-01T06:30:00Z", "");
const onChange = rs.fn();
const ui = render(
<ScheduledTaskScheduleInput
initial={initial}
onChange={onChange}
scheduleTypeLocked
/>,
);
expect(detectedZone).toHaveBeenCalled();
expect(onChange).toHaveBeenLastCalledWith({ ...initial, timezone });
expect((ui.getByLabelText("Run at") as HTMLInputElement).value).toBe(
"2026-11-01T14:30",
);
} finally {
detectedZone.mockRestore();
}
});