feat(FN-1165): add agent org chart routes and API helpers
- Add dashboard routes for /agents/org-tree and /agents/resolve/:shortname with scoped store wiring and 404 handling - Add /agents/:id/chain-of-command endpoint to return reporting hierarchy from self to top manager - Extend dashboard app API with OrgTreeNode export plus fetchOrgTree, fetchChainOfCommand, and resolveAgent helpers - Add route tests covering org-tree, resolve, and chain-of-command success and not-found scenarios
This commit is contained in:
@@ -1862,8 +1862,21 @@ export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): P
|
||||
|
||||
// ── Agent API ────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource } from "@fusion/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource };
|
||||
import type {
|
||||
Agent,
|
||||
AgentDetail,
|
||||
AgentCapability,
|
||||
AgentState,
|
||||
AgentHeartbeatEvent,
|
||||
AgentHeartbeatRun,
|
||||
AgentCreateInput,
|
||||
AgentUpdateInput,
|
||||
AgentTaskSession,
|
||||
AgentStats,
|
||||
HeartbeatInvocationSource,
|
||||
OrgTreeNode,
|
||||
} from "@fusion/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource, OrgTreeNode };
|
||||
|
||||
function withProjectId(path: string, projectId?: string): string {
|
||||
if (!projectId) return path;
|
||||
@@ -1991,6 +2004,21 @@ export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
||||
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
||||
}
|
||||
|
||||
/** Fetch the chain of command for an agent (self → manager → grand-manager → ...) */
|
||||
export function fetchChainOfCommand(agentId: string, projectId?: string): Promise<Agent[]> {
|
||||
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/chain-of-command`, projectId));
|
||||
}
|
||||
|
||||
/** Fetch the full org tree as nested nodes */
|
||||
export function fetchOrgTree(projectId?: string): Promise<OrgTreeNode[]> {
|
||||
return api<OrgTreeNode[]>(withProjectId("/agents/org-tree", projectId));
|
||||
}
|
||||
|
||||
/** Resolve an agent by shortname or ID */
|
||||
export function resolveAgent(shortname: string, projectId?: string): Promise<{ agent: Agent }> {
|
||||
return api<{ agent: Agent }>(withProjectId(`/agents/resolve/${encodeURIComponent(shortname)}`, projectId));
|
||||
}
|
||||
|
||||
/** Fetch child agents that report to a given parent agent */
|
||||
export function fetchAgentChildren(agentId: string, projectId?: string): Promise<Agent[]> {
|
||||
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => {
|
||||
|
||||
194
packages/dashboard/src/__tests__/routes-org-chart.test.ts
Normal file
194
packages/dashboard/src/__tests__/routes-org-chart.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetAgent = vi.fn();
|
||||
const mockGetChainOfCommand = vi.fn();
|
||||
const mockGetOrgTree = vi.fn();
|
||||
const mockResolveAgent = vi.fn();
|
||||
const mockListAgents = vi.fn().mockResolvedValue([]);
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockInit;
|
||||
getAgent = mockGetAgent;
|
||||
getChainOfCommand = mockGetChainOfCommand;
|
||||
getOrgTree = mockGetOrgTree;
|
||||
resolveAgent = mockResolveAgent;
|
||||
listAgents = mockListAgents;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-1165-test";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-1165-test/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createMockAgent(id: string, name: string, reportsTo?: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: {},
|
||||
reportsTo,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("Agent org chart routes", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/agents/:id/chain-of-command", () => {
|
||||
it("returns chain of command for a valid agent", async () => {
|
||||
const self = createMockAgent("agent-001", "Builder Bot", "agent-010");
|
||||
const manager = createMockAgent("agent-010", "Manager Bot");
|
||||
mockGetAgent.mockResolvedValue(self);
|
||||
mockGetChainOfCommand.mockResolvedValue([self, manager]);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/agent-001/chain-of-command");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([self, manager]);
|
||||
expect(mockGetChainOfCommand).toHaveBeenCalledWith("agent-001");
|
||||
});
|
||||
|
||||
it("returns 404 when agent is not found", async () => {
|
||||
mockGetAgent.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/missing-agent/chain-of-command");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toBe("Agent not found");
|
||||
expect(mockGetChainOfCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/org-tree", () => {
|
||||
it("returns empty array for empty store", async () => {
|
||||
mockGetOrgTree.mockResolvedValue([]);
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/org-tree");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns populated org tree", async () => {
|
||||
const ceo = createMockAgent("agent-ceo", "CEO Bot");
|
||||
const lead = createMockAgent("agent-lead", "Lead Bot", "agent-ceo");
|
||||
mockListAgents.mockResolvedValue([ceo, lead]);
|
||||
mockGetOrgTree.mockResolvedValue([
|
||||
{
|
||||
agent: ceo,
|
||||
children: [
|
||||
{
|
||||
agent: lead,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/org-tree");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
agent: ceo,
|
||||
children: [
|
||||
{
|
||||
agent: lead,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/agents/resolve/:shortname", () => {
|
||||
it("resolves by shortname", async () => {
|
||||
const agent = createMockAgent("agent-001", "Build Agent");
|
||||
mockListAgents.mockResolvedValue([agent]);
|
||||
mockResolveAgent.mockResolvedValue(agent);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/resolve/build-agent");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ agent });
|
||||
expect(mockResolveAgent).toHaveBeenCalledWith("build-agent");
|
||||
});
|
||||
|
||||
it("resolves by ID", async () => {
|
||||
const agent = createMockAgent("agent-001", "Build Agent");
|
||||
mockListAgents.mockResolvedValue([agent]);
|
||||
mockResolveAgent.mockResolvedValue(agent);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/resolve/agent-001");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ agent });
|
||||
expect(mockResolveAgent).toHaveBeenCalledWith("agent-001");
|
||||
});
|
||||
|
||||
it("returns 404 when not found", async () => {
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
mockResolveAgent.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/resolve/missing-agent");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toBe("Agent not found");
|
||||
});
|
||||
|
||||
it("returns 404 when ambiguous", async () => {
|
||||
mockListAgents.mockResolvedValue([
|
||||
createMockAgent("agent-001", "Build Agent"),
|
||||
createMockAgent("agent-002", "Build Agent"),
|
||||
]);
|
||||
mockResolveAgent.mockResolvedValue(null);
|
||||
|
||||
const response = await request(app, "GET", "/api/agents/resolve/build-agent");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toBe("Agent not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7397,6 +7397,49 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/org-tree
|
||||
* Return full agent org chart tree.
|
||||
* Must be registered before /agents/:id to avoid "org-tree" matching :id.
|
||||
*/
|
||||
router.get("/agents/org-tree", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const tree = await agentStore.getOrgTree();
|
||||
res.json(tree);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/resolve/:shortname
|
||||
* Resolve an agent by shortname or ID.
|
||||
* Must be registered before /agents/:id to avoid "resolve" matching :id.
|
||||
*/
|
||||
router.get("/agents/resolve/:shortname", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.resolveAgent(req.params.shortname);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ agent });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id
|
||||
* Get agent by ID with heartbeat history.
|
||||
@@ -8133,6 +8176,32 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/chain-of-command
|
||||
* Fetch agent reporting chain from self to top-most manager.
|
||||
* Response 200: Agent[] — [self, manager, grand-manager, ...]
|
||||
* Response 404: { error: "Agent not found" } — When target agent doesn't exist
|
||||
*/
|
||||
router.get("/agents/:id/chain-of-command", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.getAgent(req.params.id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const chain = await agentStore.getChainOfCommand(req.params.id);
|
||||
res.json(chain);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/children
|
||||
* Fetch agents that report to a given agent (parent-child hierarchy).
|
||||
|
||||
Reference in New Issue
Block a user