fix: harden cron session automation flows

This commit is contained in:
chengyongru
2026-06-12 18:13:25 +08:00
parent b5f9d51b5b
commit a50b3ac0f2
30 changed files with 267 additions and 56 deletions
+12 -4
View File
@@ -43,7 +43,7 @@ import type {
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fetchSessionAutomations, fetchSettings, fetchWorkspaces } from "@/lib/api";
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import {
createRuntimeHost,
getHostApi,
@@ -528,7 +528,15 @@ function Shell({
const { t, i18n } = useTranslation();
const { client, token } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions();
const {
sessions,
loading,
refresh,
createChat,
forkChat,
deleteChat,
getSessionAutomations,
} = useSessions();
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null);
@@ -1306,12 +1314,12 @@ function Shell({
const onRequestDelete = useCallback(async (key: string, label: string) => {
let automations: SessionAutomationJob[] = [];
try {
automations = (await fetchSessionAutomations(token, key)).jobs;
automations = await getSessionAutomations(key);
} catch {
// Delete remains protected by the backend block; prefetch only improves the first prompt.
}
setPendingDelete({ key, label, automations });
}, [token]);
}, [getSessionAutomations]);
const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] ||
+2 -6
View File
@@ -51,9 +51,7 @@ export function DeleteConfirm({
</AlertDialogTitle>
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
{hasAutomations
? t("deleteConfirm.automationsDescription", {
count: automations.length,
})
? t("deleteConfirm.automationsDescription")
: t("deleteConfirm.description")}
</AlertDialogDescription>
{hasAutomations ? (
@@ -94,9 +92,7 @@ export function DeleteConfirm({
className="h-11 rounded-full bg-destructive px-5 text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
>
{hasAutomations
? t("deleteConfirm.confirmWithAutomations", {
count: automations.length,
})
? t("deleteConfirm.confirmWithAutomations")
: t("deleteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -176,6 +176,9 @@ function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
}
if (job.state.pending) {
return { label: t("thread.sessionInfo.next.pending"), title: "" };
}
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
+18 -1
View File
@@ -5,6 +5,7 @@ import i18n from "@/i18n";
import {
ApiError,
deleteSession as apiDeleteSession,
fetchSessionAutomations,
fetchWebuiThread,
listSessions,
} from "@/lib/api";
@@ -12,6 +13,7 @@ import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import { deriveTitle } from "@/lib/format";
import type {
ChatSummary,
SessionAutomationJob,
SessionDeleteResult,
UIMessage,
WorkspaceScopePayload,
@@ -52,6 +54,7 @@ export function useSessions(): {
key: string,
options?: { deleteAutomations?: boolean },
) => Promise<SessionDeleteResult>;
getSessionAutomations: (key: string) => Promise<SessionAutomationJob[]>;
} {
const { client, token } = useClient();
const [sessions, setSessions] = useState<ChatSummary[]>([]);
@@ -159,7 +162,21 @@ export function useSessions(): {
[],
);
return { sessions, loading, error, refresh, createChat, forkChat, deleteChat };
const getSessionAutomations = useCallback(async (key: string) => {
const result = await fetchSessionAutomations(tokenRef.current, key);
return result.jobs;
}, []);
return {
sessions,
loading,
error,
refresh,
createChat,
forkChat,
deleteChat,
getSessionAutomations,
};
}
/** Lazy-load a session's on-disk messages the first time the UI displays it. */
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "{{time}}",
"pending": "Runs shortly",
"disabled": "Paused",
"none": "No next run"
}
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "Siguiente {{time}}",
"pending": "Se ejecutará pronto",
"disabled": "En pausa",
"none": "Sin próxima ejecución"
}
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "Prochaine {{time}}",
"pending": "Exécution imminente",
"disabled": "En pause",
"none": "Aucune prochaine exécution"
}
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "Berikutnya {{time}}",
"pending": "Segera berjalan",
"disabled": "Dijeda",
"none": "Tidak ada jadwal berikutnya"
}
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "次回 {{time}}",
"pending": "まもなく実行",
"disabled": "一時停止",
"none": "次回実行なし"
}
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "다음 {{time}}",
"pending": "곧 실행됨",
"disabled": "일시 중지됨",
"none": "다음 실행 없음"
}
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "Tiếp theo {{time}}",
"pending": "Sắp chạy",
"disabled": "Đã tạm dừng",
"none": "Không có lần chạy tiếp theo"
}
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "下次 {{time}}",
"pending": "即将执行",
"disabled": "已暂停",
"none": "没有下次运行"
}
+1
View File
@@ -663,6 +663,7 @@
},
"next": {
"label": "下次 {{time}}",
"pending": "即將執行",
"disabled": "已暫停",
"none": "沒有下次執行"
}
+1
View File
@@ -113,6 +113,7 @@ export interface SessionAutomationJob {
state: {
next_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null;
pending?: boolean;
};
}
+27 -21
View File
@@ -1,12 +1,14 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
import i18n from "@/i18n";
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomationJob[]>>();
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
@@ -146,6 +148,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
refresh: refreshSpy,
createChat: createChatSpy,
forkChat: async () => "fork-chat",
getSessionAutomations: getSessionAutomationsSpy,
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
if (options === undefined) await deleteChatSpy(key);
else await deleteChatSpy(key, options);
@@ -212,13 +215,15 @@ import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import App from "@/App";
describe("App layout", () => {
beforeEach(() => {
beforeEach(async () => {
await i18n.changeLanguage("en");
mockSessions = [];
connectSpy.mockClear();
updateUrlSpy.mockClear();
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
runStatusHandlers.clear();
@@ -435,7 +440,7 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("shows bound automations in the first delete confirmation", async () => {
it("shows localized bound automations in the first delete confirmation", async () => {
mockSessions = [
{
key: "websocket:chat-a",
@@ -454,44 +459,45 @@ describe("App layout", () => {
preview: "Second chat",
},
];
mockFetchRoutes({
"/api/sessions/websocket%3Achat-a/automations": {
jobs: [
{
id: "job-1",
name: "Daily repo check",
enabled: true,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: { message: "Check the repo" },
state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) },
},
],
getSessionAutomationsSpy.mockResolvedValue([
{
id: "job-1",
name: "Daily repo check",
enabled: true,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: { message: "Check the repo" },
state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) },
},
});
]);
await i18n.changeLanguage("zh-CN");
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" });
await waitFor(() =>
expect(
within(sidebar).getByRole("button", { name: /^First chat$/ }),
).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
fireEvent.pointerDown(screen.getByLabelText(/First chat.*会话操作/), {
button: 0,
});
fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "删除" }));
await waitFor(() =>
expect(screen.getByText("Daily repo check")).toBeInTheDocument(),
);
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
expect(
screen.getByText("This chat has scheduled automations. Deleting it will also delete them."),
screen.getByText("这个对话有关联的自动任务。删除对话也会删除这些自动任务。"),
).toBeInTheDocument();
expect(
screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete chat and automations" }));
fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" }));
await waitFor(() =>
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", {
+30 -3
View File
@@ -5,14 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { setAppLanguage } from "@/i18n";
function automationJob(nextRunAt = Date.now() + 3_600_000) {
function automationJob(
nextRunAt = Date.now() + 3_600_000,
state: Record<string, unknown> = {},
) {
return {
id: "job-1",
name: "Morning check",
enabled: true,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: { message: "Check the project status" },
state: { next_run_at_ms: nextRunAt },
state: { next_run_at_ms: nextRunAt, ...state },
};
}
@@ -27,7 +30,8 @@ function automationsResponse(jobs: unknown[]) {
}
describe("SessionInfoPopover", () => {
beforeEach(() => {
beforeEach(async () => {
await setAppLanguage("en");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
@@ -86,6 +90,29 @@ describe("SessionInfoPopover", () => {
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
});
it("shows a short pending label for deferred automations", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
automationsResponse([automationJob(Date.now() - 1000, { pending: true })]),
),
);
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
await user.click(screen.getByRole("button", { name: "Session details" }));
expect(await screen.findByText("Runs shortly")).toBeInTheDocument();
expect(screen.queryByText(/ago/i)).not.toBeInTheDocument();
});
it("refreshes while open so completed one-shot automations disappear", async () => {
vi.stubGlobal(
"fetch",