fix: harden cron session automation flows
This commit is contained in:
+12
-4
@@ -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] ||
|
||||
|
||||
@@ -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: "" };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "{{time}}",
|
||||
"pending": "Runs shortly",
|
||||
"disabled": "Paused",
|
||||
"none": "No next run"
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "Siguiente {{time}}",
|
||||
"pending": "Se ejecutará pronto",
|
||||
"disabled": "En pausa",
|
||||
"none": "Sin próxima ejecución"
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "Prochaine {{time}}",
|
||||
"pending": "Exécution imminente",
|
||||
"disabled": "En pause",
|
||||
"none": "Aucune prochaine exécution"
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "Berikutnya {{time}}",
|
||||
"pending": "Segera berjalan",
|
||||
"disabled": "Dijeda",
|
||||
"none": "Tidak ada jadwal berikutnya"
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "次回 {{time}}",
|
||||
"pending": "まもなく実行",
|
||||
"disabled": "一時停止",
|
||||
"none": "次回実行なし"
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "다음 {{time}}",
|
||||
"pending": "곧 실행됨",
|
||||
"disabled": "일시 중지됨",
|
||||
"none": "다음 실행 없음"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "下次 {{time}}",
|
||||
"pending": "即将执行",
|
||||
"disabled": "已暂停",
|
||||
"none": "没有下次运行"
|
||||
}
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
},
|
||||
"next": {
|
||||
"label": "下次 {{time}}",
|
||||
"pending": "即將執行",
|
||||
"disabled": "已暫停",
|
||||
"none": "沒有下次執行"
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface SessionAutomationJob {
|
||||
state: {
|
||||
next_run_at_ms?: number | null;
|
||||
last_status?: "ok" | "error" | "skipped" | string | null;
|
||||
pending?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user