feat(#233): Connect agent dashboard to real orchestrator API
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed

- Add GET /agents endpoint to orchestrator controller
- Update AgentStatusWidget to fetch from real API instead of mock data
- Add comprehensive tests for listAgents endpoint
- Auto-refresh agent list every 30 seconds
- Display agent status with proper icons and formatting
- Show error states when API is unavailable

Fixes #233

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-05 12:31:07 -06:00
parent 06fa8f7402
commit 27bbbe79df
24 changed files with 800 additions and 61 deletions

View File

@@ -13,6 +13,8 @@ describe("AgentsController", () => {
};
let spawnerService: {
spawnAgent: ReturnType<typeof vi.fn>;
listAgentSessions: ReturnType<typeof vi.fn>;
getAgentSession: ReturnType<typeof vi.fn>;
};
let lifecycleService: {
getAgentLifecycleState: ReturnType<typeof vi.fn>;
@@ -30,6 +32,8 @@ describe("AgentsController", () => {
spawnerService = {
spawnAgent: vi.fn(),
listAgentSessions: vi.fn(),
getAgentSession: vi.fn(),
};
lifecycleService = {
@@ -58,6 +62,109 @@ describe("AgentsController", () => {
expect(controller).toBeDefined();
});
describe("listAgents", () => {
it("should return empty array when no agents exist", () => {
// Arrange
spawnerService.listAgentSessions.mockReturnValue([]);
// Act
const result = controller.listAgents();
// Assert
expect(spawnerService.listAgentSessions).toHaveBeenCalled();
expect(result).toEqual([]);
});
it("should return all agent sessions with mapped status", () => {
// Arrange
const sessions = [
{
agentId: "agent-1",
taskId: "task-1",
agentType: "worker" as const,
state: "running" as const,
context: {
repository: "repo",
branch: "main",
workItems: [],
},
spawnedAt: new Date("2026-02-05T12:00:00Z"),
},
{
agentId: "agent-2",
taskId: "task-2",
agentType: "reviewer" as const,
state: "completed" as const,
context: {
repository: "repo",
branch: "main",
workItems: [],
},
spawnedAt: new Date("2026-02-05T11:00:00Z"),
completedAt: new Date("2026-02-05T11:30:00Z"),
},
{
agentId: "agent-3",
taskId: "task-3",
agentType: "tester" as const,
state: "failed" as const,
context: {
repository: "repo",
branch: "main",
workItems: [],
},
spawnedAt: new Date("2026-02-05T10:00:00Z"),
error: "Test execution failed",
},
];
spawnerService.listAgentSessions.mockReturnValue(sessions);
// Act
const result = controller.listAgents();
// Assert
expect(spawnerService.listAgentSessions).toHaveBeenCalled();
expect(result).toHaveLength(3);
expect(result[0]).toEqual({
agentId: "agent-1",
taskId: "task-1",
status: "running",
agentType: "worker",
spawnedAt: "2026-02-05T12:00:00.000Z",
completedAt: undefined,
error: undefined,
});
expect(result[1]).toEqual({
agentId: "agent-2",
taskId: "task-2",
status: "completed",
agentType: "reviewer",
spawnedAt: "2026-02-05T11:00:00.000Z",
completedAt: "2026-02-05T11:30:00.000Z",
error: undefined,
});
expect(result[2]).toEqual({
agentId: "agent-3",
taskId: "task-3",
status: "failed",
agentType: "tester",
spawnedAt: "2026-02-05T10:00:00.000Z",
completedAt: undefined,
error: "Test execution failed",
});
});
it("should handle errors gracefully", () => {
// Arrange
spawnerService.listAgentSessions.mockImplementation(() => {
throw new Error("Service unavailable");
});
// Act & Assert
expect(() => controller.listAgents()).toThrow("Failed to list agents: Service unavailable");
});
});
describe("spawn", () => {
const validRequest = {
taskId: "task-123",

View File

@@ -70,6 +70,47 @@ export class AgentsController {
}
}
/**
* List all agents
* @returns Array of all agent sessions with their status
*/
@Get()
listAgents(): {
agentId: string;
taskId: string;
status: string;
agentType: string;
spawnedAt: string;
completedAt?: string;
error?: string;
}[] {
this.logger.log("Received request to list all agents");
try {
// Get all sessions from spawner service
const sessions = this.spawnerService.listAgentSessions();
// Map to response format
const agents = sessions.map((session) => ({
agentId: session.agentId,
taskId: session.taskId,
status: session.state,
agentType: session.agentType,
spawnedAt: session.spawnedAt.toISOString(),
completedAt: session.completedAt?.toISOString(),
error: session.error,
}));
this.logger.log(`Found ${agents.length.toString()} agents`);
return agents;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to list agents: ${errorMessage}`);
throw new Error(`Failed to list agents: ${errorMessage}`);
}
}
/**
* Get agent status
* @param agentId Agent ID to query

View File

@@ -7,76 +7,103 @@ import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
interface Agent {
id: string;
name: string;
status: "IDLE" | "WORKING" | "WAITING" | "ERROR" | "TERMINATED";
currentTask?: string;
lastHeartbeat: string;
taskCount: number;
agentId: string;
taskId: string;
status: string;
agentType: string;
spawnedAt: string;
completedAt?: string;
error?: string;
}
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Mock data for now - will fetch from API later
// Fetch agents from orchestrator API
useEffect(() => {
setIsLoading(true);
setTimeout(() => {
setAgents([
{
id: "1",
name: "Code Review Agent",
status: "WORKING",
currentTask: "Reviewing PR #123",
lastHeartbeat: new Date().toISOString(),
taskCount: 42,
},
{
id: "2",
name: "Documentation Agent",
status: "IDLE",
lastHeartbeat: new Date().toISOString(),
taskCount: 15,
},
{
id: "3",
name: "Test Runner Agent",
status: "ERROR",
currentTask: "Failed to run tests",
lastHeartbeat: new Date(Date.now() - 300000).toISOString(),
taskCount: 28,
},
]);
setIsLoading(false);
}, 500);
const fetchAgents = async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
// Get orchestrator URL from environment or default to localhost
const orchestratorUrl = process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? "http://localhost:8001";
const response = await fetch(`${orchestratorUrl}/agents`, {
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch agents: ${response.statusText}`);
}
const data = (await response.json()) as Agent[];
setAgents(data);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
console.error("Failed to fetch agents:", errorMessage);
setError(errorMessage);
setAgents([]); // Clear agents on error
} finally {
setIsLoading(false);
}
};
void fetchAgents();
// Refresh every 30 seconds
const interval = setInterval(() => {
void fetchAgents();
}, 30000);
return (): void => {
clearInterval(interval);
};
}, []);
const getStatusIcon = (status: Agent["status"]): React.JSX.Element => {
switch (status) {
case "WORKING":
const getStatusIcon = (status: string): React.JSX.Element => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "running":
case "working":
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
case "IDLE":
return <Clock className="w-4 h-4 text-gray-400" />;
case "WAITING":
case "spawning":
case "queued":
return <Clock className="w-4 h-4 text-yellow-500" />;
case "ERROR":
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500" />;
case "failed":
case "error":
return <AlertCircle className="w-4 h-4 text-red-500" />;
case "TERMINATED":
case "terminated":
case "killed":
return <CheckCircle className="w-4 h-4 text-gray-500" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const getStatusText = (status: Agent["status"]): string => {
const getStatusText = (status: string): string => {
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
const getTimeSinceLastHeartbeat = (timestamp: string): string => {
const getAgentName = (agent: Agent): string => {
const typeMap: Record<string, string> = {
worker: "Worker Agent",
reviewer: "Code Review Agent",
tester: "Test Runner Agent",
};
return typeMap[agent.agentType] ?? `${getStatusText(agent.agentType)} Agent`;
};
const getTimeSinceSpawn = (timestamp: string): string => {
const now = new Date();
const last = new Date(timestamp);
const diffMs = now.getTime() - last.getTime();
const spawned = new Date(timestamp);
const diffMs = now.getTime() - spawned.getTime();
if (diffMs < 60000) return "Just now";
if (diffMs < 3600000) return `${String(Math.floor(diffMs / 60000))}m ago`;
@@ -86,9 +113,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
const stats = {
total: agents.length,
working: agents.filter((a) => a.status === "WORKING").length,
idle: agents.filter((a) => a.status === "IDLE").length,
error: agents.filter((a) => a.status === "ERROR").length,
working: agents.filter((a) => a.status.toLowerCase() === "running").length,
idle: agents.filter((a) => a.status.toLowerCase() === "spawning").length,
error: agents.filter((a) => a.status.toLowerCase() === "failed").length,
};
if (isLoading) {
@@ -99,6 +126,17 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-red-500 text-sm">
<AlertCircle className="w-4 h-4 inline mr-1" />
{error}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full space-y-3">
{/* Summary stats */}
@@ -124,15 +162,15 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
{/* Agent list */}
<div className="flex-1 overflow-auto space-y-2">
{agents.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-4">No agents configured</div>
<div className="text-center text-gray-500 text-sm py-4">No agents running</div>
) : (
agents.map((agent) => (
<div
key={agent.id}
key={agent.agentId}
className={`p-3 rounded-lg border ${
agent.status === "ERROR"
agent.status.toLowerCase() === "failed"
? "bg-red-50 border-red-200"
: agent.status === "WORKING"
: agent.status.toLowerCase() === "running"
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
}`}
@@ -140,7 +178,7 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-gray-600" />
<span className="text-sm font-medium text-gray-900">{agent.name}</span>
<span className="text-sm font-medium text-gray-900">{getAgentName(agent)}</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
{getStatusIcon(agent.status)}
@@ -148,13 +186,13 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
</div>
</div>
{agent.currentTask && (
<div className="text-xs text-gray-600 mb-1">{agent.currentTask}</div>
)}
<div className="text-xs text-gray-600 mb-1">Task: {agent.taskId}</div>
{agent.error && <div className="text-xs text-red-600 mb-1">{agent.error}</div>}
<div className="flex items-center justify-between text-xs text-gray-400">
<span>{agent.taskCount} tasks completed</span>
<span>{getTimeSinceLastHeartbeat(agent.lastHeartbeat)}</span>
<span>Agent ID: {agent.agentId.slice(0, 8)}...</span>
<span>{getTimeSinceSpawn(agent.spawnedAt)}</span>
</div>
</div>
))

View File

@@ -0,0 +1,153 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AgentStatusWidget } from "../AgentStatusWidget";
describe("AgentStatusWidget", () => {
const mockFetch = vi.fn();
beforeEach(() => {
global.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render loading state initially", () => {
mockFetch.mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-empty-function
() => new Promise(() => {}) // Never resolves
);
render(<AgentStatusWidget id="test-widget" config={{}} />);
expect(screen.getByText("Loading agents...")).toBeInTheDocument();
});
it("should fetch and display agents from API", async () => {
const mockAgents = [
{
agentId: "agent-1",
taskId: "task-1",
status: "running",
agentType: "worker",
spawnedAt: new Date().toISOString(),
},
{
agentId: "agent-2",
taskId: "task-2",
status: "completed",
agentType: "reviewer",
spawnedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
},
];
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAgents),
});
render(<AgentStatusWidget id="test-widget" config={{}} />);
await waitFor(() => {
expect(screen.getByText("Worker Agent")).toBeInTheDocument();
expect(screen.getByText("Code Review Agent")).toBeInTheDocument();
});
expect(screen.getByText("Task: task-1")).toBeInTheDocument();
expect(screen.getByText("Task: task-2")).toBeInTheDocument();
});
it("should display error message when fetch fails", async () => {
mockFetch.mockResolvedValue({
ok: false,
statusText: "Internal Server Error",
});
render(<AgentStatusWidget id="test-widget" config={{}} />);
await waitFor(() => {
expect(screen.getByText(/Failed to fetch agents: Internal Server Error/)).toBeInTheDocument();
});
});
it("should display no agents message when list is empty", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
});
render(<AgentStatusWidget id="test-widget" config={{}} />);
await waitFor(() => {
expect(screen.getByText("No agents running")).toBeInTheDocument();
});
});
it("should display agent error messages", async () => {
const mockAgents = [
{
agentId: "agent-1",
taskId: "task-1",
status: "failed",
agentType: "tester",
spawnedAt: new Date().toISOString(),
error: "Test execution failed",
},
];
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAgents),
});
render(<AgentStatusWidget id="test-widget" config={{}} />);
await waitFor(() => {
expect(screen.getByText("Test execution failed")).toBeInTheDocument();
});
});
it("should display correct stats summary", async () => {
const mockAgents = [
{
agentId: "agent-1",
taskId: "task-1",
status: "running",
agentType: "worker",
spawnedAt: new Date().toISOString(),
},
{
agentId: "agent-2",
taskId: "task-2",
status: "running",
agentType: "reviewer",
spawnedAt: new Date().toISOString(),
},
{
agentId: "agent-3",
taskId: "task-3",
status: "failed",
agentType: "tester",
spawnedAt: new Date().toISOString(),
},
];
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockAgents),
});
render(<AgentStatusWidget id="test-widget" config={{}} />);
await waitFor(() => {
// Check stats: 3 total, 2 working, 0 idle, 1 error
const stats = screen.getAllByText(/^[0-9]+$/);
expect(stats[0]).toHaveTextContent("3"); // Total
expect(stats[1]).toHaveTextContent("2"); // Working
expect(stats[2]).toHaveTextContent("0"); // Idle
expect(stats[3]).toHaveTextContent("1"); // Error
});
});
});