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:
parent
0797f933e1
commit
0a41b7dcdb
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue