diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 44c5fd2f2..42c93b46f 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -195,6 +195,7 @@ vi.mock("@/lib/services", () => ({ // ── Import routes after mocking ─────────────────────────────────────── import { GET as sessionsGET } from "@/app/api/sessions/route"; +import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; @@ -444,6 +445,94 @@ describe("API Routes", () => { }); }); + describe("POST /api/orchestrators", () => { + it("creates a per-project orchestrator with the generated prompt", async () => { + (mockSessionManager.spawnOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); + + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app" }), + headers: { "Content-Type": "application/json" }, + }); + const res = await orchestratorsPOST(req); + + expect(res.status).toBe(201); + expect(mockSessionManager.spawnOrchestrator).toHaveBeenCalledWith({ + projectId: "my-app", + systemPrompt: expect.stringContaining("# My App Orchestrator"), + }); + + const data = await res.json(); + expect(data.orchestrator).toEqual({ + id: "my-app-orchestrator", + projectId: "my-app", + projectName: "My App", + }); + }); + + it("returns 404 for an unknown project", async () => { + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "unknown-app" }), + headers: { "Content-Type": "application/json" }, + }); + const res = await orchestratorsPOST(req); + + expect(res.status).toBe(404); + const data = await res.json(); + expect(data.error).toMatch(/Unknown project/); + }); + + it("returns 400 for invalid JSON", async () => { + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: "not json", + headers: { "Content-Type": "application/json" }, + }); + + const res = await orchestratorsPOST(req); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/Invalid JSON body/); + }); + + it("returns 400 when projectId is missing", async () => { + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + const res = await orchestratorsPOST(req); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/projectId/); + }); + + it("returns 500 when orchestrator spawn fails", async () => { + (mockSessionManager.spawnOrchestrator as ReturnType).mockRejectedValueOnce( + new Error("boom"), + ); + + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app" }), + headers: { "Content-Type": "application/json" }, + }); + + const res = await orchestratorsPOST(req); + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.error).toBe("boom"); + }); + }); + // ── POST /api/sessions/:id/send ──────────────────────────────────── describe("POST /api/sessions/:id/send", () => { diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts new file mode 100644 index 000000000..03dce69ec --- /dev/null +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -0,0 +1,45 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { generateOrchestratorPrompt } from "@composio/ao-core"; +import { getServices } from "@/lib/services"; +import { validateIdentifier } from "@/lib/validation"; + +export async function POST(request: NextRequest) { + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const projectErr = validateIdentifier(body.projectId, "projectId"); + if (projectErr) { + return NextResponse.json({ error: projectErr }, { status: 400 }); + } + + try { + const { config, sessionManager } = await getServices(); + const projectId = body.projectId as string; + const project = config.projects[projectId]; + + if (!project) { + return NextResponse.json({ error: `Unknown project: ${projectId}` }, { status: 404 }); + } + + const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); + const session = await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); + + return NextResponse.json( + { + orchestrator: { + id: session.id, + projectId, + projectName: project.name, + }, + }, + { status: 201 }, + ); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to spawn orchestrator" }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 391f0aa54..f3c740b42 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -29,6 +29,20 @@ interface DashboardProps { } const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const; +const EMPTY_ORCHESTRATORS: DashboardOrchestratorLink[] = []; + +function mergeOrchestrators( + current: DashboardOrchestratorLink[], + incoming: DashboardOrchestratorLink[], +): DashboardOrchestratorLink[] { + const merged = new Map(current.map((orchestrator) => [orchestrator.projectId, orchestrator])); + + for (const orchestrator of incoming) { + merged.set(orchestrator.projectId, orchestrator); + } + + return [...merged.values()]; +} export function Dashboard({ initialSessions, @@ -36,8 +50,9 @@ export function Dashboard({ projectName, projects = [], initialGlobalPause = null, - orchestrators = [], + orchestrators, }: DashboardProps) { + const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS; const { sessions, globalPause } = useSessionEvents( initialSessions, initialGlobalPause, @@ -45,9 +60,17 @@ export function Dashboard({ ); const [rateLimitDismissed, setRateLimitDismissed] = useState(false); const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false); + const [activeOrchestrators, setActiveOrchestrators] = + useState(orchestratorLinks); + const [spawningProjectIds, setSpawningProjectIds] = useState([]); + const [spawnErrors, setSpawnErrors] = useState>({}); const showSidebar = projects.length > 1; const allProjectsView = showSidebar && projectId === undefined; + useEffect(() => { + setActiveOrchestrators((current) => mergeOrchestrators(current, orchestratorLinks)); + }, [orchestratorLinks]); + const grouped = useMemo(() => { const zones: Record = { merge: [], @@ -94,13 +117,13 @@ export function Dashboard({ return { project, orchestrator: - orchestrators.find((orchestrator) => orchestrator.projectId === project.id) ?? null, + activeOrchestrators.find((orchestrator) => orchestrator.projectId === project.id) ?? null, sessionCount: projectSessions.length, openPRCount: projectSessions.filter((session) => session.pr?.state === "open").length, counts, }; }); - }, [allProjectsView, orchestrators, projects, sessions]); + }, [activeOrchestrators, allProjectsView, projects, sessions]); const handleSend = async (sessionId: string, message: string) => { const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, { @@ -140,6 +163,44 @@ export function Dashboard({ } }; + const handleSpawnOrchestrator = async (project: ProjectInfo) => { + setSpawningProjectIds((current) => + current.includes(project.id) ? current : [...current, project.id], + ); + setSpawnErrors(({ [project.id]: _ignored, ...current }) => current); + + try { + const res = await fetch("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: project.id }), + }); + + const data = (await res.json().catch(() => null)) as { + orchestrator?: DashboardOrchestratorLink; + error?: string; + } | null; + + if (!res.ok || !data?.orchestrator) { + throw new Error(data?.error ?? `Failed to spawn orchestrator for ${project.name}`); + } + + const orchestrator = data.orchestrator; + + setActiveOrchestrators((current) => { + const next = current.filter((orchestrator) => orchestrator.projectId !== project.id); + next.push(orchestrator); + return next; + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to spawn orchestrator"; + setSpawnErrors((current) => ({ ...current, [project.id]: message })); + console.error(`Failed to spawn orchestrator for ${project.id}:`, error); + } finally { + setSpawningProjectIds((current) => current.filter((id) => id !== project.id)); + } + }; + const hasKanbanSessions = KANBAN_LEVELS.some((level) => grouped[level].length > 0); const anyRateLimited = useMemo( @@ -182,7 +243,7 @@ export function Dashboard({ - {!allProjectsView && } + {!allProjectsView && } {globalPause && !globalPauseDismissed && ( @@ -258,7 +319,14 @@ export function Dashboard({ )} - {allProjectsView && } + {allProjectsView && ( + + )} {!allProjectsView && hasKanbanSessions && (
@@ -408,6 +476,9 @@ function OrchestratorControl({ orchestrators }: { orchestrators: DashboardOrches function ProjectOverviewGrid({ overviews, + onSpawnOrchestrator, + spawningProjectIds, + spawnErrors, }: { overviews: Array<{ project: ProjectInfo; @@ -416,6 +487,9 @@ function ProjectOverviewGrid({ openPRCount: number; counts: Record; }>; + onSpawnOrchestrator: (project: ProjectInfo) => Promise; + spawningProjectIds: string[]; + spawnErrors: Record; }) { return (
@@ -462,18 +536,34 @@ function ProjectOverviewGrid({ />
-
-
- {orchestrator ? "Per-project orchestrator available" : "No running orchestrator"} +
+
+
+ {orchestrator ? "Per-project orchestrator available" : "No running orchestrator"} +
+ {orchestrator ? ( + + + orchestrator + + ) : ( + + )}
- {orchestrator ? ( - - - orchestrator - + {spawnErrors[project.id] ? ( +

+ {spawnErrors[project.id]} +

) : null}
diff --git a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx new file mode 100644 index 000000000..8039d1cfa --- /dev/null +++ b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Dashboard } from "@/components/Dashboard"; +import { makeSession } from "@/__tests__/helpers"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/", +})); + +describe("Dashboard project overview cards", () => { + beforeEach(() => { + global.EventSource = vi.fn( + () => + ({ + onmessage: null, + onerror: null, + close: vi.fn(), + }) as unknown as EventSource, + ); + global.fetch = vi.fn(); + }); + + it("renders Spawn Orchestrator only for projects without one", () => { + render( + , + ); + + expect(screen.getAllByText("My App").length).toBeGreaterThan(0); + expect(screen.getAllByText("Docs App").length).toBeGreaterThan(0); + expect(screen.getByRole("link", { name: "orchestrator" })).toHaveAttribute( + "href", + "/sessions/my-app-orchestrator", + ); + expect(screen.getByRole("button", { name: "Spawn Orchestrator" })).toBeInTheDocument(); + expect(screen.getAllByText("No running orchestrator")).toHaveLength(1); + }); + + it("remains stable when orchestrators prop is omitted", () => { + render( + , + ); + + expect(screen.getAllByRole("button", { name: "Spawn Orchestrator" })).toHaveLength(2); + }); + + it("updates the card after spawning an orchestrator", async () => { + let resolveSpawn: ((value: Response) => void) | null = null; + vi.mocked(fetch).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSpawn = resolve; + }), + ); + + render( + , + ); + + fireEvent.click(screen.getAllByRole("button", { name: "Spawn Orchestrator" })[1]); + + expect(screen.getByRole("button", { name: "Spawning..." })).toBeDisabled(); + + resolveSpawn?.({ + ok: true, + json: async () => ({ + orchestrator: { + id: "docs-orchestrator", + projectId: "docs-app", + projectName: "Docs App", + }, + }), + } as Response); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: "docs-app" }), + }); + }); + + await waitFor(() => { + const links = screen.getAllByRole("link", { name: "orchestrator" }); + expect(links).toHaveLength(1); + expect(links[0]).toHaveAttribute("href", "/sessions/docs-orchestrator"); + }); + + expect(screen.queryByText("Spawning...")).not.toBeInTheDocument(); + expect(screen.getAllByText("No running orchestrator")).toHaveLength(1); + }); + + it("shows the API error when spawning fails", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: "Project is paused" }), + } as Response); + + render( + , + ); + + fireEvent.click(screen.getAllByRole("button", { name: "Spawn Orchestrator" })[1]); + + await waitFor(() => { + expect(screen.getByText("Project is paused")).toBeInTheDocument(); + }); + expect(screen.getAllByRole("button", { name: "Spawn Orchestrator" })).toHaveLength(2); + }); +});