diff --git a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx index d151c40b65..e06fad63ba 100644 --- a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx +++ b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx @@ -22,6 +22,7 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { import { SessionsBoard } from "../../components/SessionsBoard"; import { PullRequestsPage } from "../../components/PullRequestsPage"; +import { ShellProvider } from "../../lib/shell-context"; // One ordinary project with one worker session that has multiple PRs. function respondWithProjectAndPRs() { @@ -75,7 +76,13 @@ function respondWithProjectAndPRs() { function renderWithProviders(node: ReactNode) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render({node}); + render( + + + {node} + + , + ); } beforeEach(() => { diff --git a/frontend/src/renderer/components/SessionsBoard.test.tsx b/frontend/src/renderer/components/SessionsBoard.test.tsx new file mode 100644 index 0000000000..00129d3898 --- /dev/null +++ b/frontend/src/renderer/components/SessionsBoard.test.tsx @@ -0,0 +1,88 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ShellProvider } from "../lib/shell-context"; +import { SessionsBoard } from "./SessionsBoard"; + +const { getMock, postMock, navigateMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), + navigateMock: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock, POST: postMock }, + apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"), + hasTrustedApiBaseUrl: () => true, + setApiBaseUrl: vi.fn(), +})); + +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); + +function mockWorkspace() { + getMock.mockImplementation(async (url: string) => { + if (url === "/api/v1/projects") { + return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined }; + } + if (url === "/api/v1/sessions") { + return { data: { sessions: [] }, error: undefined }; + } + throw new Error(`unexpected GET ${url}`); + }); +} + +function renderBoard(status: { state: "ready" | "stopped"; port?: number; message?: string }) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); +} + +beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + navigateMock.mockReset(); + mockWorkspace(); + window.ao!.daemon.start = vi.fn().mockResolvedValue({ state: "ready", port: 4567 }); +}); + +describe("SessionsBoard daemon recovery", () => { + it("disables daemon-backed project actions while the daemon is stopped", async () => { + renderBoard({ state: "stopped" }); + + expect(await screen.findByRole("button", { name: "New task" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Spawn Orchestrator" })).toBeDisabled(); + expect( + screen.getByText("AO daemon is stopped. Restart it to create tasks or start an orchestrator."), + ).toBeInTheDocument(); + }); + + it("offers an explicit daemon restart action", async () => { + renderBoard({ state: "stopped" }); + + await userEvent.click(await screen.findByRole("button", { name: "Restart daemon" })); + + await waitFor(() => expect(window.ao!.daemon.start).toHaveBeenCalledTimes(1)); + }); + + it("surfaces spawn errors instead of leaking an unhandled rejection", async () => { + postMock.mockResolvedValue({ + data: undefined, + error: { message: "AO daemon is not ready" }, + response: { status: 503 }, + }); + renderBoard({ state: "ready", port: 4567 }); + + await userEvent.click(await screen.findByRole("button", { name: "Spawn Orchestrator" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("AO daemon is not ready"); + }); +}); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 65b8e4934c..47338d0a53 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -8,8 +8,10 @@ import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery import { DashboardSubhead } from "./DashboardSubhead"; import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; +import { startDaemon } from "../lib/daemon-status"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display"; +import { useShell } from "../lib/shell-context"; import { cn } from "../lib/utils"; import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay"; @@ -67,6 +69,7 @@ const COLUMNS: Column[] = [ export function SessionsBoard({ projectId }: SessionsBoardProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { daemonStatus } = useShell(); const workspaceQuery = useWorkspaceQuery(); const all = workspaceQuery.data ?? []; const workspaces = projectId ? all.filter((w) => w.id === projectId) : all; @@ -76,6 +79,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { : undefined; const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); const [isSpawning, setIsSpawning] = useState(false); + const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); + const [actionError, setActionError] = useState(null); + const daemonReady = daemonStatus.state === "ready"; const byZone = new Map(); for (const session of sessions) { @@ -95,6 +101,10 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const openOrchestrator = async () => { if (!projectId) return; + if (!daemonReady) { + setActionError("AO daemon is stopped. Restart it before starting sessions."); + return; + } if (orchestrator) { void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -103,6 +113,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { return; } setIsSpawning(true); + setActionError(null); try { const sessionId = await spawnOrchestrator(projectId); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); @@ -110,11 +121,26 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId }, }); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Could not start orchestrator"); } finally { setIsSpawning(false); } }; + const restartDaemon = async () => { + setIsRestartingDaemon(true); + setActionError(null); + try { + await startDaemon(); + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Could not restart AO daemon"); + } finally { + setIsRestartingDaemon(false); + } + }; + const handleTaskCreated = async (sessionId: string) => { if (!projectId) return; await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); @@ -126,10 +152,25 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const actions = projectId ? ( <> + {!daemonReady ? ( + + ) : null} + ) : null} {isSessionRoute ? ( <> {isOrchestrator ? ( @@ -171,6 +207,7 @@ export function ShellTopbar() {