feat: add dashboard orchestrator spawn action (#441)

* feat: add dashboard orchestrator spawn action

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test: cover dashboard orchestrator spawn flow

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: stabilize dashboard orchestrator default state

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: remove stale api-routes import after rebase

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Harsh Batheja 2026-03-12 20:44:24 +05:30 committed by GitHub
parent 0797f933e1
commit 0a41b7dcdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 377 additions and 16 deletions

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).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", () => {

View File

@ -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<string, unknown> | 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 },
);
}
}

View File

@ -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<DashboardOrchestratorLink[]>(orchestratorLinks);
const [spawningProjectIds, setSpawningProjectIds] = useState<string[]>([]);
const [spawnErrors, setSpawnErrors] = useState<Record<string, string>>({});
const showSidebar = projects.length > 1;
const allProjectsView = showSidebar && projectId === undefined;
useEffect(() => {
setActiveOrchestrators((current) => mergeOrchestrators(current, orchestratorLinks));
}, [orchestratorLinks]);
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {
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({
</h1>
<StatusLine stats={liveStats} />
</div>
{!allProjectsView && <OrchestratorControl orchestrators={orchestrators} />}
{!allProjectsView && <OrchestratorControl orchestrators={activeOrchestrators} />}
</div>
{globalPause && !globalPauseDismissed && (
@ -258,7 +319,14 @@ export function Dashboard({
</div>
)}
{allProjectsView && <ProjectOverviewGrid overviews={projectOverviews} />}
{allProjectsView && (
<ProjectOverviewGrid
overviews={projectOverviews}
onSpawnOrchestrator={handleSpawnOrchestrator}
spawningProjectIds={spawningProjectIds}
spawnErrors={spawnErrors}
/>
)}
{!allProjectsView && hasKanbanSessions && (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
@ -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<AttentionLevel, number>;
}>;
onSpawnOrchestrator: (project: ProjectInfo) => Promise<void>;
spawningProjectIds: string[];
spawnErrors: Record<string, string>;
}) {
return (
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@ -462,18 +536,34 @@ function ProjectOverviewGrid({
/>
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border-subtle)] pt-3">
<div className="text-[11px] text-[var(--color-text-muted)]">
{orchestrator ? "Per-project orchestrator available" : "No running orchestrator"}
<div className="border-t border-[var(--color-border-subtle)] pt-3">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] text-[var(--color-text-muted)]">
{orchestrator ? "Per-project orchestrator available" : "No running orchestrator"}
</div>
{orchestrator ? (
<a
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-3 py-1.5 text-[11px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
</a>
) : (
<button
type="button"
onClick={() => void onSpawnOrchestrator(project)}
disabled={spawningProjectIds.includes(project.id)}
className="orchestrator-btn rounded-[7px] px-3 py-1.5 text-[11px] font-semibold disabled:cursor-wait disabled:opacity-70"
>
{spawningProjectIds.includes(project.id) ? "Spawning..." : "Spawn Orchestrator"}
</button>
)}
</div>
{orchestrator ? (
<a
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-3 py-1.5 text-[11px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
</a>
{spawnErrors[project.id] ? (
<p className="mt-2 text-[11px] text-[var(--color-status-error)]">
{spawnErrors[project.id]}
</p>
) : null}
</div>
</section>

View File

@ -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(
<Dashboard
initialSessions={[makeSession({ projectId: "my-app" })]}
projects={[
{ id: "my-app", name: "My App" },
{ id: "docs-app", name: "Docs App" },
]}
orchestrators={[{ id: "my-app-orchestrator", projectId: "my-app", projectName: "My App" }]}
/>,
);
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(
<Dashboard
initialSessions={[makeSession({ projectId: "my-app" })]}
projects={[
{ id: "my-app", name: "My App" },
{ id: "docs-app", name: "Docs App" },
]}
/>,
);
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<Response>((resolve) => {
resolveSpawn = resolve;
}),
);
render(
<Dashboard
initialSessions={[makeSession({ projectId: "my-app" })]}
projects={[
{ id: "my-app", name: "My App" },
{ id: "docs-app", name: "Docs App" },
]}
orchestrators={[]}
/>,
);
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(
<Dashboard
initialSessions={[makeSession({ projectId: "my-app" })]}
projects={[
{ id: "my-app", name: "My App" },
{ id: "docs-app", name: "Docs App" },
]}
orchestrators={[]}
/>,
);
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);
});
});