Xinmin Zeng 4fc08b4f15
feat: add scheduled tasks MVP (#3898)
* 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>
2026-07-04 21:51:57 +08:00

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",
);
});
});