mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 08:56:13 +00:00
* feat: add scheduled tasks MVP
* fix: harden scheduled task execution semantics
* feat(scheduled-tasks): preset-driven schedule form with timezone and live preview
Replace the raw cron input with a preset Select (hourly/daily/weekly/monthly/custom)
plus structured inputs (time picker, weekday toggles, day-of-month), datetime-local
for one-time tasks, a timezone selector defaulting to the browser timezone, and a
live human-readable preview. Reuses one ScheduledTaskScheduleInput for create and
edit; backend contract unchanged; zero new deps (pure Intl + DST-safe offset helpers).
* feat(scheduled-tasks): full-page i18n + recipe templates + E2E locale pin
Localize the rest of the scheduled-tasks page (filters, detail pane, actions,
edit form, run list, enum values) via t.scheduledTasks.* in en/zh. Add four
built-in recipe templates (GitHub Trending, news digest, issue triage, weekly
report) exposed as a chip row that pre-fills title + prompt + schedule. Pin
Playwright locale to en-US so E2E selectors stay stable against i18n. No backend
change, no new deps.
* fix(scheduled-tasks): idempotent 0003 migration, update head constants, future-date once test
Merge with main surfaced three CI failures:
- 0003_scheduled_tasks create_table collided with legacy test seeds that
build from full metadata; guard with inspector.has_table so the revision
no-ops when the table already exists (0004/0005 are already idempotent via
_helpers.py).
- persistence bootstrap concurrency/regression tests pinned HEAD to main's
0002_runs_token_usage; bump to the new head 0005_scheduled_task_thread_nullable.
- once-task router test used a fixed past run_at and tripped the
must-be-in-the-future validation; use a future date.
* address review: ok-check, 502 for trigger failure, mock fields, migration filename, doc fences
- fetchThreadScheduledTasks now checks response.ok like the other fetchers.
- trigger endpoint returns 502 (not 409) when dispatch fails outright, so
clients can distinguish a real conflict from a server-side failure.
- E2E mock normalizes scheduled-task objects with context_mode/last_thread_id
and nullable thread_id, matching the backend contract the UI renders against.
- Rename 0002_scheduled_tasks.py -> 0003_scheduled_tasks.py to match its
revision id (file was renamed in spirit already; filename now follows).
- CONFIGURATION.md: close the Tool Groups yaml fence and drop the stray fence
after the Scheduler notes so the sections render correctly.
* fix(scheduled-tasks): harden lease, poller, config, and frontend UX after review
* fix(scheduled-tasks): harden run lifecycle, overlap skip, non_interactive gating, and DST conversion after review
- defer a once task's terminal status to the run-completion hook; the task
stays running until the real outcome, and a startup sweep cancels once
tasks orphaned by a crash (launch-time 'completed' could stick forever)
- record interrupted runs as a distinct 'interrupted' run status with a
readable message; an interrupted once task ends 'cancelled', not 'failed'
- enforce overlap_policy=skip for fresh_thread_per_run via an active-run
pre-check (same-thread ConflictError can never fire across fresh threads)
- protect terminal run statuses from the late launch-path 'running' write
- honor context.non_interactive only for internally-authenticated callers;
arbitrary clients can no longer strip ask_clarification
- fix DST-stale timezone offset in zonedLocalToUtcIso by re-deriving the
offset at the resolved instant (once tasks fired an hour late around
spring-forward and the create->edit round-trip diverged)
- drop dead ScheduledTaskRunRepository.update_by_run_id; share one Gateway
API error helper between channels and scheduled-tasks frontends
* fix(scheduled-tasks): close review round-3 gaps in guards, concurrency, and API ergonomics
- scrub internal-only context keys (non_interactive) from the assembled run
config for non-internal callers: gating body.context alone left the same
key smuggle-able through the free-form body.config copied verbatim by
build_run_config
- guard update_after_launch with protect_terminal so the launch bookkeeping
write cannot clobber a once task already finalized by a fast-failing run's
completion hook (parent-row sibling of the run-row guard)
- reject a manual trigger while the task has an active run (409) instead of
launching a duplicate concurrent run on fresh_thread_per_run
- re-arm a terminal once task to enabled when PATCH pushes run_at into the
future; previously the endpoint returned 200 with a next_run_at that could
never be claimed
- make max_concurrent_runs a real global cap: each poll claims only into the
remaining budget of active (queued/running) scheduled runs
- paginate GET /scheduled-tasks/{id}/runs (limit<=200, offset) and push the
thread filter of /threads/{id}/scheduled-tasks into SQL
- stamp context.user_id on scheduler-launched runs, matching IM channels, so
user-scoped guardrail providers see the owning user
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
354 lines
9.1 KiB
TypeScript
354 lines
9.1 KiB
TypeScript
import { describe, expect, test } from "@rstest/core";
|
|
|
|
import {
|
|
describeSchedule,
|
|
parseCron,
|
|
serializeCron,
|
|
utcToZonedLocalInput,
|
|
zonedLocalToUtcIso,
|
|
type CronParts,
|
|
} from "@/core/scheduled-tasks/cron";
|
|
|
|
describe("serializeCron", () => {
|
|
test("hourly emits minute + star fields", () => {
|
|
expect(serializeCron("hourly", { minute: 30 } as CronParts)).toBe(
|
|
"30 * * * *",
|
|
);
|
|
});
|
|
|
|
test("daily emits minute + hour", () => {
|
|
expect(serializeCron("daily", { minute: 0, hour: 9 } as CronParts)).toBe(
|
|
"0 9 * * *",
|
|
);
|
|
});
|
|
|
|
test("weekly emits comma-joined weekday numbers in cron order (0=sun)", () => {
|
|
expect(
|
|
serializeCron("weekly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
weekdays: ["mon", "wed"],
|
|
} as CronParts),
|
|
).toBe("0 9 * * 1,3");
|
|
});
|
|
|
|
test("weekly sorts + dedupes out-of-order / duplicate weekdays", () => {
|
|
expect(
|
|
serializeCron("weekly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
weekdays: ["wed", "mon", "wed"],
|
|
} as CronParts),
|
|
).toBe("0 9 * * 1,3");
|
|
});
|
|
|
|
test("weekly maps sunday to 0", () => {
|
|
expect(
|
|
serializeCron("weekly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
weekdays: ["sun"],
|
|
} as CronParts),
|
|
).toBe("0 9 * * 0");
|
|
});
|
|
|
|
test("monthly emits day-of-month", () => {
|
|
expect(
|
|
serializeCron("monthly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
dayOfMonth: 1,
|
|
} as CronParts),
|
|
).toBe("0 9 1 * *");
|
|
});
|
|
|
|
test("custom returns raw expression", () => {
|
|
expect(serializeCron("custom", { raw: "*/5 * * * *" } as CronParts)).toBe(
|
|
"*/5 * * * *",
|
|
);
|
|
});
|
|
|
|
test("clamps out-of-range minute / hour / day-of-month", () => {
|
|
expect(serializeCron("daily", { minute: 99, hour: 24 } as CronParts)).toBe(
|
|
"59 23 * * *",
|
|
);
|
|
expect(
|
|
serializeCron("monthly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
dayOfMonth: 32,
|
|
} as CronParts),
|
|
).toBe("0 9 31 * *");
|
|
expect(
|
|
serializeCron("monthly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
dayOfMonth: 0,
|
|
} as CronParts),
|
|
).toBe("0 9 1 * *");
|
|
});
|
|
});
|
|
|
|
describe("parseCron", () => {
|
|
test("hourly: M * * * *", () => {
|
|
expect(parseCron("30 * * * *").preset).toBe("hourly");
|
|
expect(parseCron("30 * * * *").parts.minute).toBe(30);
|
|
});
|
|
|
|
test("daily: M H * * *", () => {
|
|
const r = parseCron("0 9 * * *");
|
|
expect(r.preset).toBe("daily");
|
|
expect(r.parts).toMatchObject({ minute: 0, hour: 9 });
|
|
});
|
|
|
|
test("weekly: M H * * DOW", () => {
|
|
const r = parseCron("0 9 * * 1,3");
|
|
expect(r.preset).toBe("weekly");
|
|
expect(r.parts.weekdays).toEqual(["mon", "wed"]);
|
|
});
|
|
|
|
test("weekly maps 0 and 7 to sunday", () => {
|
|
expect(parseCron("0 9 * * 0").parts.weekdays).toEqual(["sun"]);
|
|
expect(parseCron("0 9 * * 7").parts.weekdays).toEqual(["sun"]);
|
|
});
|
|
|
|
test("monthly: M H DOM * *", () => {
|
|
const r = parseCron("0 9 1 * *");
|
|
expect(r.preset).toBe("monthly");
|
|
expect(r.parts.dayOfMonth).toBe(1);
|
|
});
|
|
|
|
test("non-canonical forms fall back to custom", () => {
|
|
expect(parseCron("*/5 * * * *").preset).toBe("custom");
|
|
expect(parseCron("0 9,10 * * *").preset).toBe("custom");
|
|
expect(parseCron("0 9 * * 1-5").preset).toBe("custom");
|
|
expect(parseCron("garbage").preset).toBe("custom");
|
|
expect(parseCron("garbage").parts.raw).toBe("garbage");
|
|
});
|
|
});
|
|
|
|
describe("describeSchedule", () => {
|
|
const baseCron = {
|
|
minute: 0,
|
|
hour: 9,
|
|
weekdays: [],
|
|
dayOfMonth: 1,
|
|
} as CronParts;
|
|
|
|
test("once renders wall time + timezone (en)", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "once",
|
|
runAtLocal: "2026-07-02T09:00",
|
|
timezone: "Asia/Shanghai",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Once at 2026-07-02 09:00 (Asia/Shanghai)");
|
|
});
|
|
|
|
test("daily en", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "daily",
|
|
parts: baseCron,
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every day at 09:00 (UTC)");
|
|
});
|
|
|
|
test("daily zh", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "daily",
|
|
parts: baseCron,
|
|
timezone: "UTC",
|
|
},
|
|
"zh",
|
|
),
|
|
).toBe("每天 09:00 (UTC)");
|
|
});
|
|
|
|
test("weekly en lists weekday abbreviations", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "weekly",
|
|
parts: { ...baseCron, weekdays: ["mon", "wed"] },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every Mon, Wed at 09:00 (UTC)");
|
|
});
|
|
|
|
test("weekly zh lists 周X", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "weekly",
|
|
parts: { ...baseCron, weekdays: ["mon", "wed", "fri"] },
|
|
timezone: "UTC",
|
|
},
|
|
"zh",
|
|
),
|
|
).toBe("每周 周一、周三、周五 09:00 (UTC)");
|
|
});
|
|
|
|
test("weekly with no weekdays falls back to daily wording", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "weekly",
|
|
parts: { ...baseCron, weekdays: [] },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every day at 09:00 (UTC)");
|
|
});
|
|
|
|
test("hourly en", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "hourly",
|
|
parts: { minute: 30 },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every hour at :30 (UTC)");
|
|
});
|
|
|
|
test("monthly en", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "monthly",
|
|
parts: { minute: 0, hour: 9, dayOfMonth: 1 },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("On day 1 of every month at 09:00 (UTC)");
|
|
});
|
|
|
|
test("custom en echoes the expression", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "custom",
|
|
parts: { raw: "*/5 * * * *" },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Custom: */5 * * * * (UTC)");
|
|
});
|
|
});
|
|
|
|
describe("zonedLocalToUtcIso", () => {
|
|
test("Asia/Shanghai is UTC-8 (wall 09:00 -> 01:00Z)", () => {
|
|
expect(zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Shanghai")).toBe(
|
|
"2026-07-02T01:00:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("UTC passes through", () => {
|
|
expect(zonedLocalToUtcIso("2026-07-02T09:00", "UTC")).toBe(
|
|
"2026-07-02T09:00:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("America/New_York July is EDT (-04:00)", () => {
|
|
expect(zonedLocalToUtcIso("2026-07-02T09:00", "America/New_York")).toBe(
|
|
"2026-07-02T13:00:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("America/New_York January is EST (-05:00) — DST season flip", () => {
|
|
expect(zonedLocalToUtcIso("2026-01-15T09:00", "America/New_York")).toBe(
|
|
"2026-01-15T14:00:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("Asia/Kolkata half-hour offset UTC+5:30", () => {
|
|
expect(zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Kolkata")).toBe(
|
|
"2026-07-02T03:30:00+00:00",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("utcToZonedLocalInput", () => {
|
|
test("Shanghai +8: 01:00Z -> 09:00 wall", () => {
|
|
expect(
|
|
utcToZonedLocalInput("2026-07-02T01:00:00+00:00", "Asia/Shanghai"),
|
|
).toBe("2026-07-02T09:00");
|
|
});
|
|
|
|
test("New_York EDT: 13:00Z -> 09:00 wall", () => {
|
|
expect(
|
|
utcToZonedLocalInput("2026-07-02T13:00:00+00:00", "America/New_York"),
|
|
).toBe("2026-07-02T09:00");
|
|
});
|
|
|
|
test("invalid -> empty string", () => {
|
|
expect(utcToZonedLocalInput("not-a-date", "UTC")).toBe("");
|
|
});
|
|
|
|
test("round-trips with zonedLocalToUtcIso", () => {
|
|
const iso = zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Shanghai");
|
|
expect(utcToZonedLocalInput(iso, "Asia/Shanghai")).toBe("2026-07-02T09:00");
|
|
});
|
|
});
|
|
|
|
describe("zonedLocalToUtcIso DST transitions", () => {
|
|
// 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.
|
|
expect(zonedLocalToUtcIso("2026-03-08T03:30", "America/New_York")).toBe(
|
|
"2026-03-08T07:30:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("New_York wall time before spring-forward keeps the EST offset", () => {
|
|
expect(zonedLocalToUtcIso("2026-03-08T01:30", "America/New_York")).toBe(
|
|
"2026-03-08T06:30:00+00:00",
|
|
);
|
|
});
|
|
|
|
// US fall-back 2026: clocks repeat 01:00-02:00 EDT->EST on 2026-11-01.
|
|
test("New_York ambiguous fall-back wall time resolves deterministically", () => {
|
|
expect(zonedLocalToUtcIso("2026-11-01T01:30", "America/New_York")).toBe(
|
|
"2026-11-01T05:30:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("create -> edit round-trip survives spring-forward", () => {
|
|
const iso = zonedLocalToUtcIso("2026-03-08T03:30", "America/New_York");
|
|
expect(utcToZonedLocalInput(iso, "America/New_York")).toBe(
|
|
"2026-03-08T03:30",
|
|
);
|
|
});
|
|
|
|
test("no-DST timezone is unaffected", () => {
|
|
expect(zonedLocalToUtcIso("2026-03-08T03:30", "Asia/Shanghai")).toBe(
|
|
"2026-03-07T19:30:00+00:00",
|
|
);
|
|
});
|
|
});
|