From 4501c76b0f44cc55af6332d65ac2e7f5311f71fd Mon Sep 17 00:00:00 2001
From: tiammomo <26957354+tiammomo@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:59:52 +0800
Subject: [PATCH] feat(scheduled-tasks): browse paginated run history (#5363)
Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
---
README.md | 1 +
README_zh.md | 1 +
frontend/AGENTS.md | 8 +
.../app/workspace/scheduled-tasks/page.tsx | 93 +++++--
frontend/src/core/i18n/locales/en-US.ts | 12 +
frontend/src/core/i18n/locales/types.ts | 11 +
frontend/src/core/i18n/locales/zh-CN.ts | 11 +
frontend/src/core/scheduled-tasks/api.ts | 11 +-
frontend/src/core/scheduled-tasks/hooks.ts | 11 -
.../src/core/scheduled-tasks/run-history.ts | 45 ++++
.../tests/e2e/scheduled-run-history.spec.ts | 228 ++++++++++++++++++
frontend/tests/e2e/utils/mock-api.ts | 13 +-
.../scheduled-tasks/run-history.dom.test.tsx | 101 ++++++++
13 files changed, 512 insertions(+), 34 deletions(-)
create mode 100644 frontend/src/core/scheduled-tasks/run-history.ts
create mode 100644 frontend/tests/e2e/scheduled-run-history.spec.ts
create mode 100644 frontend/tests/unit/core/scheduled-tasks/run-history.dom.test.tsx
diff --git a/README.md b/README.md
index 85a4a22fd..1a9863344 100644
--- a/README.md
+++ b/README.md
@@ -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:
diff --git a/README_zh.md b/README_zh.md
index cf3265402..0f66cc71e 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -824,6 +824,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled
- 当某次执行处于 `queued`、`launching` 或 `running` 时冻结任务定义,避免持久化的执行意外换用新的 prompt、thread 或调度;将任务切换为暂停或删除任务会取消已在等待的执行,而 `launching`/`running` 执行结束后才能重试这些变更;显式手动触发在调度已暂停时仍可等待并执行,且不会自动恢复调度
- 支持暂停、恢复、手动触发、查看历史和删除任务
- 定时任务通过正常的 DeerFlow run 生命周期执行
+- 按每页 50 条浏览执行历史;历史页暂停自动刷新,可随时返回最新记录。 仅在读取成功后显示条数,加载中或失败不会误显示为零条。
当前 MVP 限制:
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 0248f1f9c..67b0f07c6 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -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
```
diff --git a/frontend/src/app/workspace/scheduled-tasks/page.tsx b/frontend/src/app/workspace/scheduled-tasks/page.tsx
index 7de386685..4afd85152 100644
--- a/frontend/src/app/workspace/scheduled-tasks/page.tsx
+++ b/frontend/src/app/workspace/scheduled-tasks/page.tsx
@@ -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}
-
- {(taskRunsQuery.data ?? []).length === 1
- ? st.detail.runsCountOne.replace(
- "{count}",
- String((taskRunsQuery.data ?? []).length),
- )
- : st.detail.runsCount.replace(
- "{count}",
- String((taskRunsQuery.data ?? []).length),
- )}
-
+
+
+ {st.history.newer}
+
+
+ {st.history.page.replace(
+ "{page}",
+ String(taskRunsQuery.page + 1),
+ )}
+
+
+ {st.history.older}
+
+ {taskRunsQuery.page > 0 && (
+
+ {st.history.latest}
+
+ )}
+
+ {taskRunsQuery.page > 0 && (
+
+ {st.history.paused}
+
+ )}
+ {taskRunsQuery.isPending && (
+ {st.history.loading}
+ )}
+ {taskRunsQuery.isError && (
+
+
{st.history.loadFailed}
+
void taskRunsQuery.refetch()}
+ >
+ {st.history.retry}
+
+
+ )}
+ {!taskRunsQuery.isPending && !taskRunsQuery.isError && (
+
+ {(taskRunsQuery.data ?? []).length === 1
+ ? st.detail.runsCountOne.replace(
+ "{count}",
+ String((taskRunsQuery.data ?? []).length),
+ )
+ : st.detail.runsCount.replace(
+ "{count}",
+ String((taskRunsQuery.data ?? []).length),
+ )}
+
+ )}
))
- ) : (
+ ) : !taskRunsQuery.isPending && !taskRunsQuery.isError ? (
{st.detail.noRuns}
- )}
+ ) : null}
) : (
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index e12874435..07d8edd42 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -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",
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 954020247..b670f36ac 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -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;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index b23ce4a4b..243f420c4 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -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: "排队中",
diff --git a/frontend/src/core/scheduled-tasks/api.ts b/frontend/src/core/scheduled-tasks/api.ts
index 4a735eb8d..e721526a2 100644
--- a/frontend/src/core/scheduled-tasks/api.ts
+++ b/frontend/src/core/scheduled-tasks/api.ts
@@ -36,10 +36,15 @@ export async function fetchThreadScheduledTasks(
export async function fetchScheduledTaskRuns(
taskId: string,
+ page?: { limit: number; offset: number; signal?: AbortSignal },
): Promise {
- 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,
diff --git a/frontend/src/core/scheduled-tasks/hooks.ts b/frontend/src/core/scheduled-tasks/hooks.ts
index afd048613..d6bdb6ed7 100644
--- a/frontend/src/core/scheduled-tasks/hooks.ts
+++ b/frontend/src/core/scheduled-tasks/hooks.ts
@@ -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();
diff --git a/frontend/src/core/scheduled-tasks/run-history.ts b/frontend/src/core/scheduled-tasks/run-history.ts
new file mode 100644
index 000000000..04a2e75a0
--- /dev/null
+++ b/frontend/src/core/scheduled-tasks/run-history.ts
@@ -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,
+ });
+ },
+ };
+}
diff --git a/frontend/tests/e2e/scheduled-run-history.spec.ts b/frontend/tests/e2e/scheduled-run-history.spec.ts
new file mode 100644
index 000000000..780eb2676
--- /dev/null
+++ b/frontend/tests/e2e/scheduled-run-history.spec.ts
@@ -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>,
+) {
+ 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((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");
+});
diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts
index 53c3c615c..1c338f99e 100644
--- a/frontend/tests/e2e/utils/mock-api.ts
+++ b/frontend/tests/e2e/utils/mock-api.ts
@@ -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();
diff --git a/frontend/tests/unit/core/scheduled-tasks/run-history.dom.test.tsx b/frontend/tests/unit/core/scheduled-tasks/run-history.dom.test.tsx
new file mode 100644
index 000000000..babdeafc8
--- /dev/null
+++ b/frontend/tests/unit/core/scheduled-tasks/run-history.dom.test.tsx
@@ -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 (
+ {children}
+ );
+ };
+}
+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);
+});