mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +00:00
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:
parent
796ca28f55
commit
6d725f1ccb
@ -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`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user