feat(scheduled-tasks): browse paginated run history (#5363)

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
This commit is contained in:
tiammomo 2026-09-12 12:59:52 +08:00 committed by GitHub
parent b9d6b16084
commit 4501c76b0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 512 additions and 34 deletions

View File

@ -1575,6 +1575,7 @@ Current MVP capabilities:
- Freeze a task's definition while an occurrence is `queued`, `launching`, or `running`, so a durable occurrence cannot silently pick up a different prompt, thread, or schedule; transitioning a task to paused or deleting it cancels an existing waiting occurrence, while `launching`/`running` work must finish before those mutations are retried and an explicit manual trigger may still wait and run without resuming a paused schedule
- Pause, resume, trigger, inspect history, and delete tasks
- Execute scheduled work through the normal DeerFlow run lifecycle
- Browse execution history in pages of 50; older pages pause automatic refresh, with an explicit return to the latest runs. Counts appear only after a successful read; loading and failed reads are not reported as zero runs.
Current MVP limits:

View File

@ -824,6 +824,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务scheduled
- 当某次执行处于 `queued``launching``running` 时冻结任务定义,避免持久化的执行意外换用新的 prompt、thread 或调度;将任务切换为暂停或删除任务会取消已在等待的执行,而 `launching`/`running` 执行结束后才能重试这些变更;显式手动触发在调度已暂停时仍可等待并执行,且不会自动恢复调度
- 支持暂停、恢复、手动触发、查看历史和删除任务
- 定时任务通过正常的 DeerFlow run 生命周期执行
- 按每页 50 条浏览执行历史;历史页暂停自动刷新,可随时返回最新记录。 仅在读取成功后显示条数,加载中或失败不会误显示为零条。
当前 MVP 限制:

View File

@ -44,6 +44,14 @@ Rstest runs them as two projects (`rstest.config.ts`). `*.test.ts` / `*.test.tsx
E2E tests live under `tests/e2e/` and use Playwright with Chromium. They mock all backend APIs via `page.route()` network interception and test real page interactions (navigation, chat input, streaming responses). Config: `playwright.config.ts`. The real-backend auth contract in `tests/e2e-real-backend/auth-disabled-contract.spec.ts` and `backend/tests/test_auth_me_permissions.py` pin the complete route-permission list; update both when adding registered permissions (including `projects:read/write/delete`).
The dedicated `run-history.ts` hook replaces the unpaged runs hook. Show counts
only after a successful history read, never during initial loading or errors.
Scheduled run history uses task/page query keys and the existing live offset API.
Fetch 51 rows to display 50 plus a next-page sentinel; never append pages. Only
page zero polls or refreshes on focus/reconnect. Task switches reset to page zero,
and consumed AbortSignals cancel obsolete reads. Live offsets are not snapshots;
explicit mutations or navigation may observe newly inserted runs.
## Architecture
```

View File

@ -43,12 +43,12 @@ import {
useDeleteScheduledTask,
usePauseScheduledTask,
useResumeScheduledTask,
useScheduledTaskRuns,
useScheduledTasks,
useTriggerScheduledTask,
useThreadScheduledTasks,
} from "@/core/scheduled-tasks/hooks";
import { RECIPES, type Recipe } from "@/core/scheduled-tasks/recipes";
import { useScheduledTaskRunHistory } from "@/core/scheduled-tasks/run-history";
import type {
ScheduledTask,
ScheduledTaskRun,
@ -187,7 +187,7 @@ export default function ScheduledTasksPage() {
});
const selectedTask =
filteredData.find((task) => task.id === selectedTaskId) ?? filteredData[0];
const taskRunsQuery = useScheduledTaskRuns(selectedTask?.id);
const taskRunsQuery = useScheduledTaskRunHistory(selectedTask?.id);
const createTask = useCreateScheduledTask();
const updateTask = useUpdateScheduledTask(selectedTask?.id ?? "");
const pauseTask = usePauseScheduledTask();
@ -711,17 +711,80 @@ export default function ScheduledTasksPage() {
{st.actions.delete}
</Button>
</div>
<div data-testid="scheduled-task-runs">
{(taskRunsQuery.data ?? []).length === 1
? st.detail.runsCountOne.replace(
"{count}",
String((taskRunsQuery.data ?? []).length),
)
: st.detail.runsCount.replace(
"{count}",
String((taskRunsQuery.data ?? []).length),
)}
</div>
<nav
aria-label={st.history.navigation}
className="flex flex-wrap items-center gap-2"
>
<Button
variant="outline"
size="sm"
disabled={
taskRunsQuery.page === 0 || taskRunsQuery.isFetching
}
onClick={taskRunsQuery.newer}
>
{st.history.newer}
</Button>
<span className="text-muted-foreground text-sm">
{st.history.page.replace(
"{page}",
String(taskRunsQuery.page + 1),
)}
</span>
<Button
variant="outline"
size="sm"
disabled={
!taskRunsQuery.hasOlder || taskRunsQuery.isFetching
}
onClick={taskRunsQuery.older}
>
{st.history.older}
</Button>
{taskRunsQuery.page > 0 && (
<Button
variant="outline"
size="sm"
onClick={taskRunsQuery.latest}
>
{st.history.latest}
</Button>
)}
</nav>
{taskRunsQuery.page > 0 && (
<p className="text-muted-foreground text-xs">
{st.history.paused}
</p>
)}
{taskRunsQuery.isPending && (
<p role="status">{st.history.loading}</p>
)}
{taskRunsQuery.isError && (
<div role="alert">
<p>{st.history.loadFailed}</p>
<Button
variant="outline"
size="sm"
disabled={taskRunsQuery.isFetching}
onClick={() => void taskRunsQuery.refetch()}
>
{st.history.retry}
</Button>
</div>
)}
{!taskRunsQuery.isPending && !taskRunsQuery.isError && (
<div data-testid="scheduled-task-runs">
{(taskRunsQuery.data ?? []).length === 1
? st.detail.runsCountOne.replace(
"{count}",
String((taskRunsQuery.data ?? []).length),
)
: st.detail.runsCount.replace(
"{count}",
String((taskRunsQuery.data ?? []).length),
)}
</div>
)}
<div
className="flex flex-col gap-2"
data-testid="scheduled-task-run-list"
@ -746,11 +809,11 @@ export default function ScheduledTasksPage() {
)}
</div>
))
) : (
) : !taskRunsQuery.isPending && !taskRunsQuery.isError ? (
<div className="text-muted-foreground text-sm">
{st.detail.noRuns}
</div>
)}
) : null}
</div>
</div>
) : (

View File

@ -567,6 +567,18 @@ export const enUS: Translations = {
failed: "Failed",
cancelled: "Cancelled",
},
history: {
navigation: "Run history pages",
newer: "Newer runs",
older: "Older runs",
latest: "Latest runs",
page: "Page {page}",
paused:
"Automatic refresh is paused on older pages. Return to latest for current runs.",
loading: "Loading runs…",
loadFailed: "Could not load run history.",
retry: "Retry history",
},
runTrigger: { scheduled: "scheduled", manual: "manual" },
runStatus: {
queued: "Queued",

View File

@ -473,6 +473,17 @@ export interface Translations {
failed: string;
cancelled: string;
};
history: {
navigation: string;
newer: string;
older: string;
latest: string;
page: string;
paused: string;
loading: string;
loadFailed: string;
retry: string;
};
runTrigger: { scheduled: string; manual: string };
runStatus: {
queued: string;

View File

@ -540,6 +540,17 @@ export const zhCN: Translations = {
failed: "已失败",
cancelled: "已取消",
},
history: {
navigation: "执行记录分页",
newer: "较新记录",
older: "更早记录",
latest: "最新记录",
page: "第 {page} 页",
paused: "浏览历史页时暂停自动刷新,返回最新记录可查看当前执行情况。",
loading: "正在加载执行记录…",
loadFailed: "无法加载执行记录。",
retry: "重试加载",
},
runTrigger: { scheduled: "定时", manual: "手动" },
runStatus: {
queued: "排队中",

View File

@ -36,10 +36,15 @@ export async function fetchThreadScheduledTasks(
export async function fetchScheduledTaskRuns(
taskId: string,
page?: { limit: number; offset: number; signal?: AbortSignal },
): Promise<ScheduledTaskRun[]> {
const response = await fetch(
scheduledTasksUrl(`/${encodeURIComponent(taskId)}/runs`),
);
const url = scheduledTasksUrl(`/${encodeURIComponent(taskId)}/runs`);
const response = page
? await fetch(
`${url}?${new URLSearchParams({ limit: String(page.limit), offset: String(page.offset) })}`,
{ signal: page.signal },
)
: await fetch(url);
if (!response.ok) {
await throwGatewayApiError(
response,

View File

@ -6,7 +6,6 @@ import { useI18n } from "@/core/i18n/hooks";
import {
createScheduledTask,
deleteScheduledTask,
fetchScheduledTaskRuns,
fetchScheduledTasks,
fetchThreadScheduledTasks,
pauseScheduledTask,
@ -33,16 +32,6 @@ export function useThreadScheduledTasks(threadId: string | null | undefined) {
});
}
export function useScheduledTaskRuns(taskId: string | null | undefined) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", taskId],
queryFn: () => fetchScheduledTaskRuns(taskId ?? ""),
enabled: Boolean(taskId),
refetchInterval: 15000,
refetchIntervalInBackground: false,
});
}
export function useCreateScheduledTask() {
const queryClient = useQueryClient();
const { t } = useI18n();

View File

@ -0,0 +1,45 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { fetchScheduledTaskRuns } from "./api";
export const RUN_HISTORY_PAGE_SIZE = 50;
export function useScheduledTaskRunHistory(taskId: string | undefined) {
const client = useQueryClient();
const [position, setPosition] = useState({ taskId, page: 0 });
const page = position.taskId === taskId ? position.page : 0;
if (position.taskId !== taskId) {
setPosition({ taskId, page: 0 });
}
const query = useQuery({
queryKey: ["scheduled-tasks", "runs", taskId, page],
queryFn: ({ signal }) =>
fetchScheduledTaskRuns(taskId ?? "", {
limit: RUN_HISTORY_PAGE_SIZE + 1,
offset: page * RUN_HISTORY_PAGE_SIZE,
signal,
}),
enabled: Boolean(taskId),
refetchInterval: page === 0 ? 15000 : false,
refetchIntervalInBackground: false,
refetchOnMount: page === 0,
refetchOnWindowFocus: page === 0,
refetchOnReconnect: page === 0,
});
return {
...query,
data: query.data?.slice(0, RUN_HISTORY_PAGE_SIZE),
page,
hasOlder: (query.data?.length ?? 0) > RUN_HISTORY_PAGE_SIZE,
older: () => setPosition({ taskId, page: page + 1 }),
newer: () => setPosition({ taskId, page: Math.max(0, page - 1) }),
latest: () => {
setPosition({ taskId, page: 0 });
void client.invalidateQueries({
queryKey: ["scheduled-tasks", "runs", taskId, 0],
exact: true,
});
},
};
}

View File

@ -0,0 +1,228 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const task = {
id: "history",
thread_id: "thread-1",
title: "History task",
prompt: "Summarize",
schedule_type: "cron" as const,
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled" as const,
next_run_at: null,
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 101,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const runs = (count: number) =>
Array.from({ length: count }, (_, i) => ({
id: `row-${i}`,
task_id: task.id,
thread_id: task.thread_id,
run_id: `execution-${i}`,
scheduled_for: "2026-01-01T00:00:00Z",
trigger: "scheduled" as const,
status: "success" as const,
error: null,
attempt_count: 1,
started_at: null,
finished_at: null,
created_at: "2026-01-01T00:00:00Z",
}));
const endpoint = /\/api\/scheduled-tasks\/history\/runs(?:\?|$)/;
async function seedRuns(
page: Page,
data: Record<string, ReturnType<typeof runs>>,
) {
await page.route(/\/api\/scheduled-tasks\/[^/]+\/runs(?:\?|$)/, (route) => {
const url = new URL(route.request().url());
const taskId = url.pathname.split("/").at(-2)!;
const offset = Number(url.searchParams.get("offset") ?? 0);
return route.fulfill({
json: (data[taskId] ?? []).slice(
offset,
offset + Number(url.searchParams.get("limit") ?? 50),
),
});
});
}
for (const count of [100, 101]) {
test(`browses ${count} runs without exposing the sentinel or an empty final page`, async ({
page,
}) => {
const requests: string[] = [];
page.on("request", (request) => {
if (endpoint.test(request.url()))
requests.push(new URL(request.url()).search);
});
mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] });
await seedRuns(page, { history: runs(count) });
await page.goto("/workspace/scheduled-tasks");
const list = page.getByTestId("scheduled-task-run-list");
const older = page.getByRole("button", { name: "Older runs", exact: true });
await expect(list.getByText(/^execution-\d+$/)).toHaveCount(50);
await expect(list.getByText("execution-50", { exact: true })).toHaveCount(
0,
);
await older.click();
await expect(list.getByText("execution-50", { exact: true })).toBeVisible();
await expect(list.getByText(/^execution-\d+$/)).toHaveCount(50);
if (count === 101) {
await older.click();
await expect(
list.getByText("execution-100", { exact: true }),
).toBeVisible();
await expect(list.getByText(/^execution-\d+$/)).toHaveCount(1);
}
await expect(older).toBeDisabled();
await page.getByRole("button", { name: "Newer runs", exact: true }).click();
await expect(
page.getByRole("navigation", { name: "Run history pages" }),
).toContainText(count === 101 ? "Page 2" : "Page 1");
if (count === 101)
await page
.getByRole("button", { name: "Latest runs", exact: true })
.click();
await expect(list.getByText("execution-0", { exact: true })).toBeVisible();
expect(requests).toContain("?limit=51&offset=50");
expect(requests).not.toContain("?limit=51&offset=150");
});
}
test("history load failure is retriable and switching tasks resets the page", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [task, { ...task, id: "other", title: "Other history" }],
});
await seedRuns(page, {
history: runs(101),
other: [
{
...runs(1)[0]!,
id: "other-row",
task_id: "other",
run_id: "other-execution",
},
],
});
let fail = true;
await page.route(endpoint, (route) => {
const url = new URL(route.request().url());
if (url.searchParams.get("offset") === "50" && fail)
return route.fulfill({ status: 500, json: { detail: "unavailable" } });
return route.fallback();
});
await page.goto("/workspace/scheduled-tasks");
await page.getByRole("button", { name: "Older runs", exact: true }).click();
await expect(
page.getByRole("alert").filter({ hasText: "Could not load run history." }),
).toContainText("Could not load run history.", { timeout: 15000 });
await expect(page.getByTestId("scheduled-task-runs")).toHaveCount(0);
fail = false;
await page
.getByRole("button", { name: "Retry history", exact: true })
.click();
await expect(page.getByTestId("scheduled-task-run-list")).toContainText(
"execution-50",
);
await page.getByTestId("scheduled-task-item-other").click();
await expect(page.getByTestId("scheduled-task-run-list")).toContainText(
"other-execution",
);
await expect(
page.getByRole("navigation", { name: "Run history pages" }),
).toContainText("Page 1");
await expect(
page.getByRole("button", { name: "Newer runs", exact: true }),
).toBeDisabled();
});
test("only latest history polls and returning to latest fetches newly inserted runs", async ({
page,
}) => {
await page.clock.install();
mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] });
const rows = runs(101);
const offsets: number[] = [];
await page.route(endpoint, (route) => {
const url = new URL(route.request().url());
const offset = Number(url.searchParams.get("offset") ?? 0);
offsets.push(offset);
return route.fulfill({
json: rows.slice(
offset,
offset + Number(url.searchParams.get("limit") ?? 50),
),
});
});
await page.goto("/workspace/scheduled-tasks");
const list = page.getByTestId("scheduled-task-run-list");
await expect(list).toContainText("execution-0");
const initialRequests = offsets.length;
await page.clock.fastForward(16000);
await expect.poll(() => offsets.length).toBeGreaterThan(initialRequests);
await page.getByRole("button", { name: "Older runs", exact: true }).click();
await expect(list).toContainText("execution-50");
const olderRequests = offsets.length;
rows.unshift({ ...rows[0]!, id: "inserted", run_id: "new-execution" });
await page.clock.fastForward(31000);
await page.evaluate(() => {
window.dispatchEvent(new Event("offline"));
window.dispatchEvent(new Event("online"));
document.dispatchEvent(new Event("visibilitychange"));
});
expect(offsets.length).toBe(olderRequests);
await expect(list).toContainText("execution-50");
await page.getByRole("button", { name: "Latest runs", exact: true }).click();
await expect(list).toContainText("new-execution");
expect(offsets.at(-1)).toBe(0);
});
test("Chinese history navigation and empty results are localized", async ({
page,
}) => {
mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] });
await page.goto("/workspace/scheduled-tasks");
await page.evaluate(() => {
document.cookie = "locale=zh-CN; path=/";
});
await page.reload();
const nav = page.getByRole("navigation", { name: "执行记录分页" });
await expect(nav).toContainText("第 1 页");
await expect(
nav.getByRole("button", { name: "更早记录", exact: true }),
).toBeDisabled();
await expect(
nav.getByRole("button", { name: "较新记录", exact: true }),
).toBeDisabled();
});
test("pending history does not report an empty run count", async ({ page }) => {
mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] });
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(endpoint, async (route) => {
await gate;
await route.fulfill({ json: [] });
});
try {
await page.goto("/workspace/scheduled-tasks");
await expect(
page.getByRole("status").filter({ hasText: "Loading runs" }),
).toBeVisible();
await expect(page.getByTestId("scheduled-task-runs")).toHaveCount(0);
} finally {
release();
}
await expect(page.getByTestId("scheduled-task-runs")).toContainText("0 runs");
});

View File

@ -677,15 +677,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
void page.route("**/api/scheduled-tasks/*/runs", (route) => {
void page.route(/\/api\/scheduled-tasks\/[^/]+\/runs(?:\?|$)/, (route) => {
if (route.request().method() === "GET") {
const taskId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
const url = new URL(route.request().url());
const taskId = decodeURIComponent(url.pathname.split("/").at(-2) ?? "");
const offset = Number(url.searchParams.get("offset") ?? 0);
const limit = Number(url.searchParams.get("limit") ?? 50);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mutableTaskRuns[taskId] ?? []),
body: JSON.stringify(
(mutableTaskRuns[taskId] ?? []).slice(offset, offset + limit),
),
});
}
return route.fallback();

View File

@ -0,0 +1,101 @@
import { afterEach, expect, test, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
rs.mock("@/core/scheduled-tasks/api", () => ({
fetchScheduledTaskRuns: rs.fn(),
}));
import { fetchScheduledTaskRuns } from "@/core/scheduled-tasks/api";
import { useScheduledTaskRunHistory } from "@/core/scheduled-tasks/run-history";
import type { ScheduledTaskRun } from "@/core/scheduled-tasks/types";
const fetchRuns = rs.mocked(fetchScheduledTaskRuns);
const clients: QueryClient[] = [];
function wrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
clients.push(client);
return function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};
}
const rows = (count: number, prefix = "run") =>
Array.from(
{ length: count },
(_, index) => ({ id: `${prefix}-${index}` }) as ScheduledTaskRun,
);
afterEach(() => {
cleanup();
clients.splice(0).forEach((client) => client.clear());
fetchRuns.mockReset();
});
test("a full final page does not expose the next-page sentinel", async () => {
fetchRuns.mockImplementation(async (_task, page) =>
rows(page?.offset === 0 ? 51 : 50),
);
const { result } = renderHook(() => useScheduledTaskRunHistory("task-a"), {
wrapper: wrapper(),
});
await waitFor(() => expect(result.current.hasOlder).toBe(true));
expect(result.current.data).toHaveLength(50);
act(() => result.current.older());
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.hasOlder).toBe(false);
expect(result.current.data).toHaveLength(50);
expect(fetchRuns).toHaveBeenLastCalledWith("task-a", {
limit: 51,
offset: 50,
signal: expect.any(AbortSignal),
});
});
test("switching tasks aborts an in-flight older page and rejects its late result", async () => {
let oldSignal: AbortSignal | undefined;
let finishOld!: (data: ScheduledTaskRun[]) => void;
fetchRuns.mockImplementation(async (id, page) => {
if (id === "task-a" && page?.offset === 50) {
oldSignal = page.signal;
return new Promise((resolve) => {
finishOld = resolve;
});
}
return rows(id === "task-a" ? 51 : 1, id);
});
const { result, rerender } = renderHook(
({ taskId }) => useScheduledTaskRunHistory(taskId),
{ initialProps: { taskId: "task-a" }, wrapper: wrapper() },
);
await waitFor(() => expect(result.current.hasOlder).toBe(true));
act(() => result.current.older());
await waitFor(() => expect(oldSignal).toBeDefined());
expect(result.current.isPending).toBe(true);
rerender({ taskId: "task-b" });
await waitFor(() => expect(result.current.data?.[0]?.id).toBe("task-b-0"));
expect(result.current.page).toBe(0);
expect(oldSignal?.aborted).toBe(true);
await act(async () => finishOld(rows(50, "stale-task-a")));
expect(result.current.data?.[0]?.id).toBe("task-b-0");
});
test("an empty older page retains a route back to the latest records", async () => {
fetchRuns.mockImplementation(async (_id, page) =>
rows(page?.offset === 0 ? 51 : 0),
);
const { result } = renderHook(() => useScheduledTaskRunHistory("task-a"), {
wrapper: wrapper(),
});
await waitFor(() => expect(result.current.hasOlder).toBe(true));
act(() => result.current.older());
await waitFor(() => expect(result.current.data).toEqual([]));
expect(result.current.page).toBe(1);
fetchRuns.mockImplementation(async () => rows(1, "new"));
act(() => result.current.latest());
await waitFor(() => expect(result.current.data?.[0]?.id).toBe("new-0"));
expect(result.current.page).toBe(0);
});