deer-flow/frontend/src/components/workspace/scheduled-task-schedule-input.tsx
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

334 lines
9.3 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useI18n } from "@/core/i18n/hooks";
import {
describeSchedule,
pad2,
parseCron,
serializeCron,
utcToZonedLocalInput,
WEEKDAYS,
zonedLocalToUtcIso,
type CronParts,
type CronPreset,
type ScheduleLocale,
type Weekday,
} from "@/core/scheduled-tasks/cron";
export type ScheduleValue = {
schedule_type: "once" | "cron";
schedule_spec: { cron?: string; run_at?: string };
timezone: string;
};
const PRESETS: CronPreset[] = [
"hourly",
"daily",
"weekly",
"monthly",
"custom",
];
const FALLBACK_TIMEZONES = [
"UTC",
"Asia/Shanghai",
"Asia/Tokyo",
"Asia/Singapore",
"Europe/London",
"Europe/Berlin",
"America/New_York",
"America/Chicago",
"America/Los_Angeles",
];
function detectBrowserTimezone(): string {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (typeof tz === "string" && tz.length > 0) {
return tz;
}
} catch {
// resolvedOptions unavailable
}
return "UTC";
}
function timezoneOptions(): string[] {
const supported = (
Intl as unknown as {
supportedValuesOf?: (key: string) => string[] | undefined;
}
).supportedValuesOf?.("timeZone");
if (Array.isArray(supported) && supported.length > 0) {
return supported;
}
return FALLBACK_TIMEZONES;
}
const TIMEZONE_OPTIONS = timezoneOptions();
export function ScheduledTaskScheduleInput({
initial,
onChange,
scheduleTypeLocked = false,
}: {
initial: ScheduleValue;
onChange: (value: ScheduleValue) => void;
scheduleTypeLocked?: boolean;
}) {
const { t, locale } = useI18n();
const schedLocale: ScheduleLocale = locale.startsWith("zh") ? "zh" : "en";
const labels = t.scheduledTasks;
const [scheduleType, setScheduleType] = useState<"once" | "cron">(
initial.schedule_type,
);
const [preset, setPreset] = useState<CronPreset>(
() => parseCron(initial.schedule_spec.cron ?? "0 9 * * *").preset,
);
const [parts, setParts] = useState<CronParts>(
() => parseCron(initial.schedule_spec.cron ?? "0 9 * * *").parts,
);
const [runAtLocal, setRunAtLocal] = useState<string>(
initial.schedule_type === "once" && initial.schedule_spec.run_at
? utcToZonedLocalInput(
initial.schedule_spec.run_at,
initial.timezone || "UTC",
)
: "",
);
const [timezone, setTimezone] = useState<string>(
initial.timezone || detectBrowserTimezone(),
);
// Hold the latest onChange in a ref so the effect below does not depend on
// it. This avoids a re-render loop: if the parent passes an inline
// onChange (new reference each render), depending on it directly would
// re-fire the effect every render and call onChange again, looping.
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Emit on every change including mount. On mount this syncs the parent with
// the browser-detected timezone and the canonicalized cron, so the submitted
// value always matches what the user sees in the preview.
useEffect(() => {
if (scheduleType === "once") {
const runAt = runAtLocal ? zonedLocalToUtcIso(runAtLocal, timezone) : "";
onChangeRef.current({
schedule_type: "once",
schedule_spec: runAt ? { run_at: runAt } : {},
timezone,
});
return;
}
const cron =
preset === "custom" ? (parts.raw ?? "") : serializeCron(preset, parts);
onChangeRef.current({
schedule_type: "cron",
schedule_spec: cron ? { cron } : {},
timezone,
});
}, [scheduleType, preset, parts, runAtLocal, timezone]);
function updateParts(patch: Partial<CronParts>) {
setParts((prev) => ({ ...prev, ...patch }));
}
function changePreset(next: CronPreset) {
setParts((prev) => {
const merged = { ...prev };
if (next === "weekly" && (merged.weekdays ?? []).length === 0) {
merged.weekdays = ["mon"];
}
if (next === "monthly" && merged.dayOfMonth == null) {
merged.dayOfMonth = 1;
}
if (next === "custom" && !merged.raw) {
merged.raw = serializeCron("daily", prev);
}
return merged;
});
setPreset(next);
}
function toggleWeekday(w: Weekday) {
setParts((prev) => {
const set = new Set(prev.weekdays ?? []);
if (set.has(w)) {
if (set.size <= 1) {
return prev;
}
set.delete(w);
} else {
set.add(w);
}
return { ...prev, weekdays: WEEKDAYS.filter((d) => set.has(d)) };
});
}
const preview = describeSchedule(
{ scheduleType, preset, parts, runAtLocal, timezone },
schedLocale,
);
return (
<div className="flex flex-col gap-2" data-testid="schedule-input">
{!scheduleTypeLocked && (
<div className="flex flex-wrap gap-2">
<Button
variant={scheduleType === "cron" ? "default" : "outline"}
size="sm"
onClick={() => setScheduleType("cron")}
>
{labels.scheduleType.cron}
</Button>
<Button
variant={scheduleType === "once" ? "default" : "outline"}
size="sm"
onClick={() => setScheduleType("once")}
>
{labels.scheduleType.once}
</Button>
</div>
)}
{scheduleType === "cron" ? (
<>
<Select
value={preset}
onValueChange={(v) => changePreset(v as CronPreset)}
>
<SelectTrigger className="w-full" data-testid="schedule-preset">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRESETS.map((p) => (
<SelectItem key={p} value={p}>
{labels.preset[p]}
</SelectItem>
))}
</SelectContent>
</Select>
{preset === "hourly" && (
<Input
type="number"
min={0}
max={59}
value={parts.minute ?? 0}
onChange={(e) => updateParts({ minute: Number(e.target.value) })}
aria-label={labels.fields.minute}
/>
)}
{(preset === "daily" ||
preset === "weekly" ||
preset === "monthly") && (
<Input
type="time"
value={`${pad2(parts.hour ?? 9)}:${pad2(parts.minute ?? 0)}`}
onChange={(e) => {
const [h, m] = e.target.value.split(":").map(Number);
updateParts({ hour: h, minute: m });
}}
aria-label={labels.fields.time}
/>
)}
{preset === "weekly" && (
<div className="flex flex-wrap gap-1">
<span className="text-muted-foreground w-full text-sm">
{labels.fields.weekday}
</span>
{WEEKDAYS.map((w) => {
const active = (parts.weekdays ?? []).includes(w);
return (
<Button
key={w}
variant={active ? "default" : "outline"}
size="sm"
onClick={() => toggleWeekday(w)}
aria-pressed={active}
>
{labels.weekdays[w]}
</Button>
);
})}
</div>
)}
{preset === "monthly" && (
<Input
type="number"
min={1}
max={31}
value={parts.dayOfMonth ?? 1}
onChange={(e) =>
updateParts({ dayOfMonth: Number(e.target.value) })
}
aria-label={labels.fields.dayOfMonth}
/>
)}
{preset === "custom" && (
<div className="flex flex-col gap-1">
<Input
value={parts.raw ?? ""}
onChange={(e) => updateParts({ raw: e.target.value })}
placeholder={labels.fields.cronPlaceholder}
aria-label={labels.fields.cron}
/>
<a
href="https://crontab.guru/"
target="_blank"
rel="noreferrer"
className="text-muted-foreground text-xs hover:underline"
>
{labels.cronHelp}
</a>
</div>
)}
</>
) : (
<Input
type="datetime-local"
value={runAtLocal}
onChange={(e) => setRunAtLocal(e.target.value)}
aria-label={labels.fields.runAt}
/>
)}
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger className="w-full" data-testid="schedule-timezone">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEZONE_OPTIONS.map((tzOption) => (
<SelectItem key={tzOption} value={tzOption}>
{tzOption}
</SelectItem>
))}
</SelectContent>
</Select>
<div
className="text-muted-foreground text-sm"
data-testid="schedule-preview"
>
{preview}
</div>
</div>
);
}