mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-15 01:08:53 +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>
334 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|