mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(frontend): align one-time DST conversion with the scheduler (#6756)
Co-authored-by: LingYi-Liang <270316896+LingYi-Liang@users.noreply.github.com>
This commit is contained in:
parent
33b8e84f11
commit
96f930f5be
@ -2929,6 +2929,7 @@ 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.
|
||||
- Existing one-time tasks display the scheduler's execution time even when a conversation or API saved a local time skipped by daylight saving. Local times without an explicit offset use the first occurrence of a repeated hour.
|
||||
- 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
|
||||
- Sending `assistant_id: null` in a scheduled-task PATCH resets the task to `lead_agent`; omitting `assistant_id` preserves the current agent, including when that custom agent has since been deleted
|
||||
|
||||
@ -207,6 +207,11 @@ 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.
|
||||
|
||||
Wall-time conversion matches the scheduler's `ZoneInfo(fold=0)` policy: choose
|
||||
the first occurrence of a repeated time and the pre-transition offset for a
|
||||
skipped time. Stored one-time schedules use that instant for display; the form's
|
||||
round-trip validation must still reject newly entered skipped times.
|
||||
|
||||
## Resources
|
||||
|
||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
|
||||
|
||||
@ -542,8 +542,9 @@ export function describeTaskSchedule(
|
||||
|
||||
/**
|
||||
* Convert a datetime-local wall value (interpreted in `timezone`) to a UTC ISO
|
||||
* string. The backend's `datetime.fromisoformat` treats naive strings as UTC,
|
||||
* so we must submit an explicit offset. DST-safe via Intl.
|
||||
* string. Match the scheduler's ZoneInfo(fold=0) reading: use the first
|
||||
* occurrence of a repeated time and the pre-transition offset for a skipped
|
||||
* time. Form validation rejects skipped times separately.
|
||||
*/
|
||||
export function zonedLocalToUtcIso(
|
||||
localValue: string,
|
||||
@ -553,19 +554,20 @@ export function zonedLocalToUtcIso(
|
||||
const [y, mo, d] = (date ?? "").split("-").map(Number);
|
||||
const [h, mi] = (time ?? "00:00").split(":").map(Number);
|
||||
const refMs = Date.UTC(y ?? 1970, (mo ?? 1) - 1, d ?? 1, h ?? 0, mi ?? 0);
|
||||
// The offset at the wall-time-as-UTC instant is stale when a DST transition
|
||||
// sits between that instant and the real one (e.g. "03:30" the morning of
|
||||
// spring-forward resolves with the pre-transition offset, firing an hour
|
||||
// late and breaking the round-trip with `utcToZonedLocalInput`). Resolve
|
||||
// once, then re-derive the offset at the candidate instant; one correction
|
||||
// converges for real timezones.
|
||||
let offsetMs = tzOffsetMs(timezone, new Date(refMs));
|
||||
let utcMs = refMs - offsetMs;
|
||||
const correctedOffsetMs = tzOffsetMs(timezone, new Date(utcMs));
|
||||
if (correctedOffsetMs !== offsetMs) {
|
||||
offsetMs = correctedOffsetMs;
|
||||
utcMs = refMs - offsetMs;
|
||||
}
|
||||
// Look on both sides of a possible transition, including half-hour shifts
|
||||
// and a skipped calendar day. Iterating one offset guess can oscillate in
|
||||
// a gap or settle on the second occurrence of a repeated time.
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const before = tzOffsetMs(timezone, new Date(refMs - dayMs));
|
||||
const after = tzOffsetMs(timezone, new Date(refMs + dayMs));
|
||||
const candidates = [...new Set([refMs - before, refMs - after])].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const utcMs =
|
||||
candidates.find(
|
||||
(candidate) =>
|
||||
candidate + tzOffsetMs(timezone, new Date(candidate)) === refMs,
|
||||
) ?? Math.max(...candidates);
|
||||
const utc = new Date(utcMs);
|
||||
return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(
|
||||
utc.getUTCDate(),
|
||||
|
||||
@ -788,6 +788,42 @@ test("reuse-thread option explains the queue behaviour in the dialog", async ({
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a stored DST gap shows the scheduled instant and a title edit preserves it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.setFixedTime(new Date("2026-08-01T00:00:00Z"));
|
||||
const patches = writesTo(page, "PATCH", "/api/scheduled-tasks/dst-gap");
|
||||
await openPage(page, {
|
||||
scheduledTasks: [
|
||||
task({
|
||||
id: "dst-gap",
|
||||
title: "Midnight reminder",
|
||||
schedule_type: "once",
|
||||
schedule_spec: { run_at: "2026-09-06" },
|
||||
timezone: "America/Santiago",
|
||||
next_run_at: "2026-09-06T04:00:00+00:00",
|
||||
}),
|
||||
],
|
||||
});
|
||||
// The server-provided next_run_at line was already correct on main.
|
||||
// Check the schedule description itself, not any matching date in the panel.
|
||||
await expect(
|
||||
detail(page).getByText("Once, Sep 6 01:00 (America/Santiago)", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await detail(page).getByRole("button", { name: "Edit" }).click();
|
||||
await expect(form(page).getByLabel("Run at")).toHaveValue("2026-09-06T01:00");
|
||||
await form(page)
|
||||
.getByLabel("Title", { exact: true })
|
||||
.fill("Renamed reminder");
|
||||
await form(page).getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(detail(page).getByRole("heading")).toHaveText(
|
||||
"Renamed reminder",
|
||||
);
|
||||
expect(patches).toEqual([{ title: "Renamed reminder" }]);
|
||||
});
|
||||
|
||||
for (const locale of ["en-US", "zh-CN"] as const) {
|
||||
test(`list and detail show no raw identifiers (${locale})`, async ({
|
||||
page,
|
||||
|
||||
@ -122,6 +122,28 @@ test("parent feedback and title-only rerenders retain the original instant", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("a stored skipped midnight opens at the scheduler's time without rewriting it", () => {
|
||||
const initial = once("2026-09-06", "America/Santiago");
|
||||
const onChange = rs.fn();
|
||||
const ui = render(
|
||||
<ScheduledTaskScheduleInput
|
||||
initial={initial}
|
||||
onChange={onChange}
|
||||
scheduleTypeLocked
|
||||
/>,
|
||||
);
|
||||
const input = ui.getByLabelText("Run at") as HTMLInputElement;
|
||||
expect(input.value).toBe("2026-09-06T01:00");
|
||||
expect(onChange).toHaveBeenLastCalledWith(initial);
|
||||
|
||||
fireEvent.change(input, { target: { value: "2026-09-06T02:00" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
once("2026-09-06T05:00:00+00:00", "America/Santiago"),
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "2026-09-06T01:00" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(initial);
|
||||
});
|
||||
|
||||
test("changing timezone recomputes the instant and reverting restores seconds", () => {
|
||||
const initial = once("2027-06-01T12:30:45Z");
|
||||
const onChange = rs.fn();
|
||||
|
||||
@ -493,6 +493,21 @@ describe("utcToZonedLocalInput", () => {
|
||||
});
|
||||
|
||||
describe("zonedLocalToUtcIso DST transitions", () => {
|
||||
// Expected instants match scheduler.next_run_at's ZoneInfo(fold=0) reading.
|
||||
test.each([
|
||||
["America/Santiago", "2026-09-06T00:00", "2026-09-06T04:00:00+00:00"],
|
||||
["America/Santiago", "2026-09-06T00:30", "2026-09-06T04:30:00+00:00"],
|
||||
["America/New_York", "2026-03-08T02:30", "2026-03-08T07:30:00+00:00"],
|
||||
["Europe/Berlin", "2026-03-29T02:30", "2026-03-29T01:30:00+00:00"],
|
||||
["Europe/Berlin", "2026-10-25T02:30", "2026-10-25T00:30:00+00:00"],
|
||||
["Australia/Lord_Howe", "2026-10-04T02:15", "2026-10-03T15:45:00+00:00"],
|
||||
["Australia/Lord_Howe", "2026-04-05T01:45", "2026-04-04T14:45:00+00:00"],
|
||||
["Pacific/Apia", "2011-12-30T12:00", "2011-12-30T22:00:00+00:00"],
|
||||
])("matches the scheduler at %s %s", (timezone, local, expected) => {
|
||||
expect(zonedLocalToUtcIso(local, timezone)).toBe(expected);
|
||||
expect(onceRunAtInstant(local, timezone)).toBe(expected);
|
||||
});
|
||||
|
||||
// US spring-forward 2026: clocks jump 02:00 -> 03:00 EST->EDT on 2026-03-08.
|
||||
test("New_York wall time after spring-forward uses the post-transition offset", () => {
|
||||
// 03:30 EDT (-4) is 07:30Z; the stale pre-transition offset (-5) would say 08:30Z.
|
||||
@ -654,6 +669,19 @@ describe("describeTaskSchedule", () => {
|
||||
});
|
||||
|
||||
describe("describeTaskSchedule for a chat-created one-time task", () => {
|
||||
test("a stored skipped midnight displays when the scheduler will run it", () => {
|
||||
const task = {
|
||||
schedule_type: "once" as const,
|
||||
schedule_spec: { run_at: "2026-09-06" },
|
||||
timezone: "America/Santiago",
|
||||
};
|
||||
expect(
|
||||
describeTaskSchedule(task, "en-US", {
|
||||
now: new Date("2026-08-01T00:00:00Z"),
|
||||
}).text,
|
||||
).toBe("Once, Sep 6 01:00 (America/Santiago)");
|
||||
});
|
||||
|
||||
test("reads the agent's local run_at in the task's zone, whatever the viewer's zone", () => {
|
||||
const task = {
|
||||
schedule_type: "once" as const,
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
|
||||
describe("one-time local time validation", () => {
|
||||
test.each([
|
||||
["America/Santiago", "2026-09-06T00:30"],
|
||||
["America/New_York", "2027-03-14T02:30"],
|
||||
["Australia/Lord_Howe", "2027-10-03T02:15"],
|
||||
["Pacific/Apia", "2011-12-30T12:00"],
|
||||
@ -21,6 +22,7 @@ describe("one-time local time validation", () => {
|
||||
["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"],
|
||||
["Europe/Berlin", "2026-10-25T02:30", "2026-10-25T00: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"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user