fix(frontend): preserve schedule when switching to custom cron (#6749)

Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
This commit is contained in:
haotianqi666 2026-10-12 20:04:48 +08:00 committed by GitHub
parent 65fa321ec1
commit 338f8819a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 136 additions and 2 deletions

View File

@ -2796,6 +2796,8 @@ Current MVP limits:
Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigger uses the same scheduled-task resource and execution path.
In the task form, switching an hourly, daily, weekly, or monthly schedule to **Custom cron** starts from its current timing, including selected weekdays or day of the month. Switching back to Custom cron after preset edits uses those edits rather than an older expression.
### Lifecycle, safety caps and stop conditions
- The tasks page and the REST API (`POST` / `PATCH /api/scheduled-tasks`) accept the same per-run goal (`goal_objective`), safety cap (`max_runs`, `end_at`) and stop condition (`stop_condition`) as a conversation. Sending `null` in a PATCH clears any of these four; an `end_at` without a UTC offset is wall-clock time in the task's timezone.

View File

@ -140,6 +140,12 @@ matching in `core/scheduled-tasks/search.ts`; clearing search retains other filt
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.
The scheduled-task schedule input initializes Custom cron from the current
preset and its current fields on every transition into the custom editor.
Do not substitute a daily schedule or restore stale `parts.raw` after preset
edits. Component tests cover emitted specs; browser tests pin unchanged
schedules through the task form's PATCH payload on desktop and mobile.
## Environment
Scheduled-task interval forms preserve the initial `every_seconds` on mount,

View File

@ -223,8 +223,9 @@ export function ScheduledTaskScheduleInput({
if (next === "monthly" && merged.dayOfMonth == null) {
merged.dayOfMonth = 1;
}
if (next === "custom" && !merged.raw) {
merged.raw = serializeCron("daily", prev);
if (next === "custom" && preset !== "custom") {
// Open the current schedule, not a daily fallback or an older draft.
merged.raw = serializeCron(preset, prev);
}
return merged;
});

View File

@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
for (const width of [1280, 375]) {
for (const cron of ["30 14 * * 1,3,5", "30 14 21 * *"]) {
test(`custom cron preserves ${cron} through saving at width ${width}`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 900 });
const patches: Record<string, unknown>[] = [];
page.on("request", (request) => {
if (
request.method() === "PATCH" &&
new URL(request.url()).pathname === "/api/scheduled-tasks/cron-task"
) {
patches.push(request.postDataJSON() as Record<string, unknown>);
}
});
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "cron-task",
thread_id: null,
title: "Scheduled report",
prompt: "Prepare the report",
schedule_type: "cron",
schedule_spec: { cron },
timezone: "UTC",
status: "enabled",
next_run_at: "2099-01-21T14:30:00Z",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-10-01T00:00:00Z",
updated_at: "2026-10-01T00:00:00Z",
},
],
});
await page.goto("/workspace/scheduled-tasks");
await page
.getByTestId("scheduled-task-detail")
.getByRole("button", { name: "Edit", exact: true })
.click();
const form = page.getByTestId("scheduled-task-form");
await form.getByTestId("schedule-preset").click();
await page.getByRole("option", { name: "Custom cron" }).click();
await expect(form.getByLabel("Cron expression")).toHaveValue(cron);
await form.getByLabel("Title", { exact: true }).fill("Renamed report");
await form.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => patches.length).toBe(1);
expect(patches[0]).toEqual({ title: "Renamed report" });
await expect(form).toHaveCount(0);
});
}
}

View File

@ -10,6 +10,70 @@ import { I18nProvider } from "@/core/i18n/context";
afterEach(cleanup);
describe("ScheduledTaskScheduleInput", () => {
async function selectPreset(name: string) {
fireEvent.keyDown(screen.getByTestId("schedule-preset"), {
key: "ArrowDown",
});
fireEvent.click(await screen.findByRole("option", { name }));
}
function renderCron(cron: string) {
const emitted: ScheduleValue[] = [];
render(
<I18nProvider initialLocale="en-US">
<ScheduledTaskScheduleInput
initial={{
schedule_type: "cron",
schedule_spec: { cron },
timezone: "UTC",
}}
onChange={(value) => emitted.push(value)}
/>
</I18nProvider>,
);
return emitted;
}
test.each(["15 * * * *", "30 14 * * *", "30 14 * * 1,3,5", "30 14 21 * *"])(
"keeps %s when opening the custom cron editor",
async (cron) => {
const emitted = renderCron(cron);
await selectPreset("Custom cron");
expect(
screen.getByRole("textbox", { name: "Cron expression" }),
).toHaveProperty("value", cron);
expect(emitted.at(-1)?.schedule_spec).toEqual({ cron });
},
);
test("reopening custom cron uses the current preset instead of a stale expression", async () => {
const emitted = renderCron("*/10 * * * *");
await selectPreset("Weekly");
fireEvent.change(screen.getByLabelText("Time"), {
target: { value: "16:45" },
});
expect(emitted.at(-1)?.schedule_spec).toEqual({ cron: "45 16 * * 1" });
await selectPreset("Custom cron");
expect(
screen.getByRole("textbox", { name: "Cron expression" }),
).toHaveProperty("value", "45 16 * * 1");
expect(emitted.at(-1)?.schedule_spec).toEqual({ cron: "45 16 * * 1" });
fireEvent.change(screen.getByRole("textbox", { name: "Cron expression" }), {
target: { value: "*/10 * * * *" },
});
expect(emitted.at(-1)).toEqual({
schedule_type: "cron",
schedule_spec: { cron: "*/10 * * * *" },
timezone: "UTC",
});
});
test.each([1, 30, 59])(
"preserves an existing %s-second interval until explicitly edited",
(everySeconds) => {