feat(FN-2547): split dashboard agent routes into modular registrars
- Extract core agent CRUD and assignment endpoints into a dedicated registrar - Extract runtime, reflection/rating, and import/export/generation endpoints into focused route modules - Add a dedicated skills route registrar and wire all new registrars through the main routes entrypoint - Update routes documentation with registrar breakdown and registration ordering guidance
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,11 @@ The context provides core cross-cutting plumbing:
|
||||
- `register-messaging-scripts.ts` — scripts API and mailbox/message routes
|
||||
- `register-git-github.ts` — git/GitHub workflows and related helpers
|
||||
- `register-files-terminal-workspaces.ts` — files, terminal, workspace file operations
|
||||
- `register-agents-projects-nodes.ts` — agents, project metadata, node routes
|
||||
- `register-agent-core-routes.ts` — core agent CRUD, lookups, stats/org-tree, hierarchy aliases (`/agents/:id/children|employees`)
|
||||
- `register-agent-runtime-routes.ts` — agent runtime/control-plane, heartbeats/runs, access/permissions, soul/memory, revisions/budget/keys, task/inbox surfaces
|
||||
- `register-agent-reflection-rating-routes.ts` — reflection/performance/context endpoints and ratings APIs
|
||||
- `register-agent-import-export-generation-routes.ts` — agent import/export, companies catalog, and `/agents/generate/*` session/spec lifecycle
|
||||
- `register-agent-skills-routes.ts` — skills discovery/content/execution/catalog endpoints coupled to agent capability flow
|
||||
- `register-plugins-automation.ts` — plugin CRUD, automation, routines/webhooks
|
||||
- `register-proxy.ts` — remote-node proxy forwarding and SSE proxy routes
|
||||
|
||||
@@ -42,6 +46,10 @@ Express matches in registration order. Keep registrar and in-registrar route ord
|
||||
1. **Specific operation routes before generic parameterized routes** (`/runs`, `/runs/:id`, `/copy`, `/delete` before `/:id` style handlers)
|
||||
2. **Specific operation routes before wildcard paths** (`/files/{*filepath}/copy|move|delete` before catch-all file write routes)
|
||||
3. **Do not move proxy/script/message/file wildcards ahead of specific routes**
|
||||
4. **Agent ordering constraints must stay intact**:
|
||||
- `/agents/stats`, `/agents/org-tree`, `/agents/resolve/:shortname` before `/agents/:id`
|
||||
- `/agents/:id/runs/stop` before `/agents/:id/runs/:runId`
|
||||
- `/agents/:id/reflections/latest` before `/agents/:id/reflections`
|
||||
|
||||
If adding a new endpoint, place it in the domain registrar and verify it does not shadow existing handlers.
|
||||
|
||||
|
||||
533
packages/dashboard/src/routes/register-agent-core-routes.ts
Normal file
533
packages/dashboard/src/routes/register-agent-core-routes.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
interface AgentCoreRouteDeps {
|
||||
sanitizeAgentTaskLinks: (agents: Agent[], scopedStore: TaskStore) => Promise<Agent[]>;
|
||||
validateAgentInstructionsPayload: (instructionsPath: unknown, instructionsText: unknown) => boolean;
|
||||
}
|
||||
|
||||
export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps;
|
||||
|
||||
/**
|
||||
* GET /api/agents
|
||||
* List all agents with optional filtering.
|
||||
* Query params: state, role, includeEphemeral
|
||||
*/
|
||||
router.get("/agents", async (req, res) => {
|
||||
try {
|
||||
const filter: { state?: string; role?: string; includeEphemeral?: boolean } = {};
|
||||
if (req.query.state && typeof req.query.state === "string") {
|
||||
filter.state = req.query.state;
|
||||
}
|
||||
if (req.query.role && typeof req.query.role === "string") {
|
||||
filter.role = req.query.role;
|
||||
}
|
||||
if (req.query.includeEphemeral === "true") {
|
||||
filter.includeEphemeral = true;
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "paused" | "terminated"; role?: AgentCapability; includeEphemeral?: boolean });
|
||||
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
||||
res.json(sanitizedAgents);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents
|
||||
* Create a new agent.
|
||||
*/
|
||||
router.post("/agents", async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
role,
|
||||
metadata,
|
||||
title,
|
||||
icon,
|
||||
reportsTo,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
instructionsPath,
|
||||
instructionsText,
|
||||
soul,
|
||||
memory,
|
||||
bundleConfig,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
throw badRequest("name is required");
|
||||
}
|
||||
if (!role || typeof role !== "string") {
|
||||
throw badRequest("role is required");
|
||||
}
|
||||
if (metadata !== undefined && (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))) {
|
||||
throw badRequest("metadata must be an object");
|
||||
}
|
||||
if (title !== undefined && title !== null && typeof title !== "string") {
|
||||
throw badRequest("title must be a string");
|
||||
}
|
||||
if (icon !== undefined && icon !== null && typeof icon !== "string") {
|
||||
throw badRequest("icon must be a string");
|
||||
}
|
||||
if (reportsTo !== undefined && reportsTo !== null && typeof reportsTo !== "string") {
|
||||
throw badRequest("reportsTo must be a string");
|
||||
}
|
||||
if (runtimeConfig !== undefined && (typeof runtimeConfig !== "object" || runtimeConfig === null || Array.isArray(runtimeConfig))) {
|
||||
throw badRequest("runtimeConfig must be an object");
|
||||
}
|
||||
if (permissions !== undefined && (typeof permissions !== "object" || permissions === null || Array.isArray(permissions))) {
|
||||
throw badRequest("permissions must be an object");
|
||||
}
|
||||
if (!validateAgentInstructionsPayload(instructionsPath, instructionsText)) {
|
||||
return;
|
||||
}
|
||||
if (soul !== undefined && soul !== null && typeof soul !== "string") {
|
||||
throw badRequest("soul must be a string");
|
||||
}
|
||||
if (typeof soul === "string" && soul.length > 10000) {
|
||||
throw badRequest("soul must be at most 10,000 characters");
|
||||
}
|
||||
if (memory !== undefined && memory !== null && typeof memory !== "string") {
|
||||
throw badRequest("memory must be a string");
|
||||
}
|
||||
if (typeof memory === "string" && memory.length > 50000) {
|
||||
throw badRequest("memory must be at most 50,000 characters");
|
||||
}
|
||||
if (bundleConfig !== undefined && bundleConfig !== null) {
|
||||
if (typeof bundleConfig !== "object" || Array.isArray(bundleConfig)) {
|
||||
throw badRequest("bundleConfig must be an object");
|
||||
}
|
||||
if (typeof bundleConfig.mode !== "string" || !["managed", "external"].includes(bundleConfig.mode)) {
|
||||
throw badRequest("bundleConfig.mode must be 'managed' or 'external'");
|
||||
}
|
||||
if (typeof bundleConfig.entryFile !== "string") {
|
||||
throw badRequest("bundleConfig.entryFile must be a string");
|
||||
}
|
||||
if (!Array.isArray(bundleConfig.files)) {
|
||||
throw badRequest("bundleConfig.files must be an array");
|
||||
}
|
||||
if (bundleConfig.externalPath !== undefined && typeof bundleConfig.externalPath !== "string") {
|
||||
throw badRequest("bundleConfig.externalPath must be a string");
|
||||
}
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.createAgent({
|
||||
name,
|
||||
role: role as AgentCapability,
|
||||
metadata,
|
||||
title: title ?? undefined,
|
||||
icon: icon ?? undefined,
|
||||
reportsTo: reportsTo ?? undefined,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
soul: soul ?? undefined,
|
||||
memory: memory ?? undefined,
|
||||
bundleConfig: bundleConfig ?? undefined,
|
||||
});
|
||||
res.status(201).json(agent);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("required") || (err instanceof Error ? err.message : String(err)).includes("cannot be empty")) {
|
||||
throw badRequest(err instanceof Error ? err.message : String(err));
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps;
|
||||
|
||||
/**
|
||||
* GET /api/agents/stats
|
||||
* Return aggregate stats across all agents.
|
||||
* Must be registered before /agents/:id to avoid "stats" matching :id.
|
||||
* Note: assignedTaskCount excludes agents whose linked task is in a terminal state.
|
||||
*/
|
||||
router.get("/agents/stats", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agents = await agentStore.listAgents();
|
||||
const activeCount = agents.filter((a) => a.state === "active" || a.state === "running").length;
|
||||
|
||||
// Count only agents with non-terminal linked tasks
|
||||
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
||||
const assignedTaskCount = sanitizedAgents.filter((a) => a.taskId).length;
|
||||
|
||||
let completedRuns = 0;
|
||||
let failedRuns = 0;
|
||||
for (const agent of agents) {
|
||||
const runs = await agentStore.getRecentRuns(agent.id, 100);
|
||||
completedRuns += runs.filter((r) => r.status === "completed").length;
|
||||
failedRuns += runs.filter((r) => r.status === "failed" || r.status === "terminated").length;
|
||||
}
|
||||
|
||||
const total = completedRuns + failedRuns;
|
||||
const successRate = total > 0 ? completedRuns / total : 0;
|
||||
res.json({ activeCount, assignedTaskCount, completedRuns, failedRuns, successRate });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const includeEphemeral = req.query.includeEphemeral === "true";
|
||||
const tree = await agentStore.getOrgTree({ includeEphemeral });
|
||||
res.json(tree);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 { store: scopedStore } = await getProjectContext(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) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
res.json({ agent });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id
|
||||
* Get agent by ID with heartbeat history.
|
||||
* taskId is omitted from response if the linked task is in a terminal state.
|
||||
*/
|
||||
router.get("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.getAgentDetail(req.params.id, 50);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
// Sanitize taskId for single-agent responses (omit if linked task is terminal)
|
||||
const [sanitizedAgent] = await sanitizeAgentTaskLinks([agent], scopedStore);
|
||||
res.json(sanitizedAgent);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/agents/:id
|
||||
* Update agent fields.
|
||||
*/
|
||||
router.patch("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const body = req.body ?? {};
|
||||
const updates: AgentUpdateInput = {};
|
||||
|
||||
if ("name" in body) {
|
||||
if (body.name !== null && typeof body.name !== "string") {
|
||||
throw badRequest("name must be a string");
|
||||
}
|
||||
updates.name = body.name ?? undefined;
|
||||
}
|
||||
|
||||
if ("role" in body) {
|
||||
if (body.role !== null && typeof body.role !== "string") {
|
||||
throw badRequest("role must be a string");
|
||||
}
|
||||
updates.role = body.role ?? undefined;
|
||||
}
|
||||
|
||||
if ("metadata" in body) {
|
||||
if (body.metadata !== null && (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
|
||||
throw badRequest("metadata must be an object");
|
||||
}
|
||||
updates.metadata = body.metadata ?? undefined;
|
||||
}
|
||||
|
||||
if ("title" in body) {
|
||||
if (body.title !== null && typeof body.title !== "string") {
|
||||
throw badRequest("title must be a string");
|
||||
}
|
||||
updates.title = body.title ?? undefined;
|
||||
}
|
||||
|
||||
if ("icon" in body) {
|
||||
if (body.icon !== null && typeof body.icon !== "string") {
|
||||
throw badRequest("icon must be a string");
|
||||
}
|
||||
updates.icon = body.icon ?? undefined;
|
||||
}
|
||||
|
||||
if ("reportsTo" in body) {
|
||||
if (body.reportsTo !== null && typeof body.reportsTo !== "string") {
|
||||
throw badRequest("reportsTo must be a string");
|
||||
}
|
||||
updates.reportsTo = body.reportsTo ?? undefined;
|
||||
}
|
||||
|
||||
if ("pauseReason" in body) {
|
||||
if (body.pauseReason !== null && typeof body.pauseReason !== "string") {
|
||||
throw badRequest("pauseReason must be a string");
|
||||
}
|
||||
updates.pauseReason = body.pauseReason ?? undefined;
|
||||
}
|
||||
|
||||
if ("runtimeConfig" in body) {
|
||||
if (body.runtimeConfig !== null && (typeof body.runtimeConfig !== "object" || Array.isArray(body.runtimeConfig))) {
|
||||
throw badRequest("runtimeConfig must be an object");
|
||||
}
|
||||
updates.runtimeConfig = body.runtimeConfig ?? undefined;
|
||||
}
|
||||
|
||||
if ("permissions" in body) {
|
||||
if (body.permissions !== null && (typeof body.permissions !== "object" || Array.isArray(body.permissions))) {
|
||||
throw badRequest("permissions must be an object");
|
||||
}
|
||||
updates.permissions = body.permissions ?? undefined;
|
||||
}
|
||||
|
||||
if ("totalInputTokens" in body) {
|
||||
if (body.totalInputTokens !== null && typeof body.totalInputTokens !== "number") {
|
||||
throw badRequest("totalInputTokens must be a number");
|
||||
}
|
||||
updates.totalInputTokens = body.totalInputTokens ?? undefined;
|
||||
}
|
||||
|
||||
if ("totalOutputTokens" in body) {
|
||||
if (body.totalOutputTokens !== null && typeof body.totalOutputTokens !== "number") {
|
||||
throw badRequest("totalOutputTokens must be a number");
|
||||
}
|
||||
updates.totalOutputTokens = body.totalOutputTokens ?? undefined;
|
||||
}
|
||||
|
||||
if (!validateAgentInstructionsPayload(body.instructionsPath, body.instructionsText)) {
|
||||
return;
|
||||
}
|
||||
if ("instructionsPath" in body) {
|
||||
updates.instructionsPath = body.instructionsPath ?? undefined;
|
||||
}
|
||||
if ("instructionsText" in body) {
|
||||
updates.instructionsText = body.instructionsText ?? undefined;
|
||||
}
|
||||
|
||||
if ("soul" in body) {
|
||||
if (body.soul !== null && typeof body.soul !== "string") {
|
||||
throw badRequest("soul must be a string");
|
||||
}
|
||||
if (typeof body.soul === "string" && body.soul.length > 10000) {
|
||||
throw badRequest("soul must be at most 10,000 characters");
|
||||
}
|
||||
updates.soul = body.soul ?? undefined;
|
||||
}
|
||||
|
||||
if ("memory" in body) {
|
||||
if (body.memory !== null && typeof body.memory !== "string") {
|
||||
throw badRequest("memory must be a string");
|
||||
}
|
||||
if (typeof body.memory === "string" && body.memory.length > 50000) {
|
||||
throw badRequest("memory must be at most 50,000 characters");
|
||||
}
|
||||
updates.memory = body.memory ?? undefined;
|
||||
}
|
||||
|
||||
if ("bundleConfig" in body) {
|
||||
if (body.bundleConfig !== null) {
|
||||
if (typeof body.bundleConfig !== "object" || Array.isArray(body.bundleConfig)) {
|
||||
throw badRequest("bundleConfig must be an object");
|
||||
}
|
||||
if (typeof body.bundleConfig.mode !== "string" || !["managed", "external"].includes(body.bundleConfig.mode)) {
|
||||
throw badRequest("bundleConfig.mode must be 'managed' or 'external'");
|
||||
}
|
||||
if (typeof body.bundleConfig.entryFile !== "string") {
|
||||
throw badRequest("bundleConfig.entryFile must be a string");
|
||||
}
|
||||
if (!Array.isArray(body.bundleConfig.files)) {
|
||||
throw badRequest("bundleConfig.files must be an array");
|
||||
}
|
||||
if (body.bundleConfig.externalPath !== undefined && typeof body.bundleConfig.externalPath !== "string") {
|
||||
throw badRequest("bundleConfig.externalPath must be a string");
|
||||
}
|
||||
}
|
||||
updates.bundleConfig = body.bundleConfig ?? undefined;
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.updateAgent(req.params.id, updates);
|
||||
res.json(agent);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
} else if ((err instanceof Error ? err.message : String(err)).includes("cannot be empty")) {
|
||||
throw badRequest(err instanceof Error ? err.message : String(err));
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/:id
|
||||
* Delete an agent.
|
||||
*/
|
||||
router.delete("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
await agentStore.deleteAgent(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 { store: scopedStore } = await getProjectContext(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) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
const chain = await agentStore.getChainOfCommand(req.params.id);
|
||||
res.json(chain);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/children
|
||||
* Fetch agents that report to a given agent (parent-child hierarchy).
|
||||
* Response 200: Agent[] — Array of agents where reportsTo equals :id
|
||||
* Response 404: { error: "Agent not found" } — When parent agent doesn't exist
|
||||
*/
|
||||
const getAgentEmployeesHandler = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!agentId) {
|
||||
throw badRequest("Agent id is required");
|
||||
}
|
||||
|
||||
// Validate the parent agent exists
|
||||
const parent = await agentStore.getAgent(agentId);
|
||||
if (!parent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
const children = await agentStore.getAgentsByReportsTo(agentId);
|
||||
res.json(children);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
};
|
||||
|
||||
router.get("/agents/:id/children", getAgentEmployeesHandler);
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/employees
|
||||
* Alias for /api/agents/:id/children.
|
||||
*/
|
||||
router.get("/agents/:id/employees", getAgentEmployeesHandler);
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import * as fsPromises from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline as streamPipeline } from "node:stream/promises";
|
||||
import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js";
|
||||
import { createSessionDiagnostics } from "../ai-session-diagnostics.js";
|
||||
import {
|
||||
startAgentGeneration,
|
||||
generateAgentSpec,
|
||||
getAgentGenerationSession,
|
||||
cleanupAgentGenerationSession,
|
||||
RateLimitError as AgentGenerationRateLimitError,
|
||||
SessionNotFoundError as AgentGenerationSessionNotFoundError,
|
||||
} from "../agent-generation.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
const { mkdtemp, access, stat, mkdir, rm, writeFile: fsWriteFile } = fsPromises;
|
||||
|
||||
export function registerAgentImportExportRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
|
||||
|
||||
/**
|
||||
* POST /api/agents/export
|
||||
* Export agents to an Agent Companies package directory.
|
||||
*
|
||||
* Body:
|
||||
* - { agentIds?: string[]; companyName?: string; companySlug?: string; outputDir?: string }
|
||||
*/
|
||||
router.post("/agents/export", async (req, res) => {
|
||||
try {
|
||||
const { agentIds, companyName, companySlug, outputDir } = req.body ?? {};
|
||||
|
||||
if (agentIds !== undefined) {
|
||||
if (!Array.isArray(agentIds)) {
|
||||
throw badRequest("agentIds must be an array of strings");
|
||||
}
|
||||
if (agentIds.some((id: unknown) => typeof id !== "string" || id.trim().length === 0)) {
|
||||
throw badRequest("agentIds must contain non-empty strings");
|
||||
}
|
||||
}
|
||||
|
||||
if (companyName !== undefined && typeof companyName !== "string") {
|
||||
throw badRequest("companyName must be a string");
|
||||
}
|
||||
if (companySlug !== undefined && typeof companySlug !== "string") {
|
||||
throw badRequest("companySlug must be a string");
|
||||
}
|
||||
if (outputDir !== undefined && typeof outputDir !== "string") {
|
||||
throw badRequest("outputDir must be a string");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore, exportAgentsToDirectory } = await import("@fusion/core");
|
||||
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const allAgents = await agentStore.listAgents();
|
||||
const requestedIds = Array.isArray(agentIds) ? [...new Set(agentIds.map((id) => id.trim()))] : [];
|
||||
const agentsToExport = requestedIds.length > 0
|
||||
? allAgents.filter((agent) => requestedIds.includes(agent.id))
|
||||
: allAgents;
|
||||
|
||||
if (agentsToExport.length === 0) {
|
||||
throw badRequest("No agents found to export");
|
||||
}
|
||||
|
||||
let resolvedOutputDir: string;
|
||||
if (typeof outputDir === "string" && outputDir.trim().length > 0) {
|
||||
resolvedOutputDir = resolve(outputDir.trim());
|
||||
} else if (typeof outputDir === "string") {
|
||||
throw badRequest("outputDir cannot be empty");
|
||||
} else {
|
||||
resolvedOutputDir = await mkdtemp(join(tmpdir(), "fusion-agent-export-"));
|
||||
}
|
||||
|
||||
const result = await exportAgentsToDirectory(agentsToExport, resolvedOutputDir, {
|
||||
companyName: typeof companyName === "string" ? companyName : undefined,
|
||||
companySlug: typeof companySlug === "string" ? companySlug : undefined,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Companies.sh company entry from the catalog API.
|
||||
*/
|
||||
interface CompaniesShCompany {
|
||||
slug: string;
|
||||
name: string;
|
||||
tagline?: string;
|
||||
repo?: string;
|
||||
website?: string;
|
||||
installs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a company slug from companies.sh.
|
||||
* Slugs must be lowercase alphanumeric with hyphens, 1-50 chars.
|
||||
*/
|
||||
function isValidCompanySlug(slug: unknown): slug is string {
|
||||
if (typeof slug !== "string") return false;
|
||||
return /^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$/.test(slug) || /^[a-z0-9]$/.test(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/agents/companies
|
||||
* Browse companies from companies.sh catalog.
|
||||
* Returns normalized company entries for UI display.
|
||||
*/
|
||||
router.get("/agents/companies", async (_req, res) => {
|
||||
try {
|
||||
const COMPANIES_SH_API = "https://companies.sh/api/companies";
|
||||
|
||||
let companies: CompaniesShCompany[] = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(COMPANIES_SH_API, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "fn-dashboard/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`companies.sh API returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new Error(`companies.sh API returned non-JSON content: ${contentType}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as unknown;
|
||||
|
||||
// Handle array response directly
|
||||
if (Array.isArray(data)) {
|
||||
companies = data.map((item): CompaniesShCompany | null => {
|
||||
if (typeof item !== "object" || item === null) return null;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const slug = typeof entry.slug === "string" ? entry.slug : undefined;
|
||||
const name = typeof entry.name === "string" ? entry.name : undefined;
|
||||
|
||||
// Skip entries without required fields or with invalid slugs
|
||||
if (!slug || !name || !isValidCompanySlug(slug)) return null;
|
||||
|
||||
return {
|
||||
slug,
|
||||
name,
|
||||
tagline: typeof entry.tagline === "string" ? entry.tagline : undefined,
|
||||
repo: typeof entry.repo === "string" ? entry.repo : undefined,
|
||||
website: typeof entry.website === "string" ? entry.website : undefined,
|
||||
installs: typeof entry.installs === "number" ? entry.installs
|
||||
: typeof entry.installs === "string" ? parseInt(entry.installs, 10) || undefined
|
||||
: undefined,
|
||||
};
|
||||
}).filter((c): c is CompaniesShCompany => c !== null);
|
||||
} else if (typeof data === "object" && data !== null) {
|
||||
// Handle wrapped response: { items: [...] }, { companies: [...] }, or { data: [...] }
|
||||
const obj = data as Record<string, unknown>;
|
||||
const arr = Array.isArray(obj.items) ? obj.items
|
||||
: Array.isArray(obj.companies) ? obj.companies
|
||||
: Array.isArray(obj.data) ? obj.data
|
||||
: [];
|
||||
|
||||
companies = (arr as unknown[]).map((item): CompaniesShCompany | null => {
|
||||
if (typeof item !== "object" || item === null) return null;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const slug = typeof entry.slug === "string" ? entry.slug : undefined;
|
||||
const name = typeof entry.name === "string" ? entry.name : undefined;
|
||||
|
||||
if (!slug || !name || !isValidCompanySlug(slug)) return null;
|
||||
|
||||
return {
|
||||
slug,
|
||||
name,
|
||||
tagline: typeof entry.tagline === "string" ? entry.tagline : undefined,
|
||||
repo: typeof entry.repo === "string" ? entry.repo : undefined,
|
||||
website: typeof entry.website === "string" ? entry.website : undefined,
|
||||
installs: typeof entry.installs === "number" ? entry.installs
|
||||
: typeof entry.installs === "string" ? parseInt(entry.installs, 10) || undefined
|
||||
: undefined,
|
||||
};
|
||||
}).filter((c): c is CompaniesShCompany => c !== null);
|
||||
}
|
||||
} catch (fetchErr) {
|
||||
// Return empty array + error message on network/parsing errors
|
||||
const message = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
||||
if (message.includes("aborted")) {
|
||||
throw new Error("companies.sh request timed out");
|
||||
}
|
||||
// Log and include error in response so frontend can display it
|
||||
runtimeLogger.child("agents/companies").warn(`Failed to fetch catalog: ${message}`);
|
||||
res.json({ companies, error: `Failed to fetch companies.sh catalog: ${message}` });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ companies });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Import Skill Persistence Helpers ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Slugify a string for safe use in filesystem paths.
|
||||
* Removes dangerous characters, normalizes whitespace/unicode, limits to alphanumeric + hyphens.
|
||||
*/
|
||||
function slugifyPathSegment(value: string, fallback: string): string {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate YAML frontmatter + markdown body string.
|
||||
*/
|
||||
function toSkillMarkdown(frontmatter: Record<string, unknown>, body: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
lines.push(` - ${String(item)}`);
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
||||
} else {
|
||||
lines.push(`${key}: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return `---\n${lines.join("\n")}\n---\n${body}`;
|
||||
}
|
||||
|
||||
interface SkillImportResult {
|
||||
imported: Array<{ name: string; path: string }>;
|
||||
skipped: string[];
|
||||
errors: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
interface SkillManifestForImport {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
slug?: unknown;
|
||||
schema?: unknown;
|
||||
kind?: unknown;
|
||||
version?: unknown;
|
||||
license?: unknown;
|
||||
authors?: unknown;
|
||||
tags?: unknown;
|
||||
instructionBody?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist skill manifests from an Agent Companies package to the project skills directory.
|
||||
* Skills are written to: {projectRoot}/skills/imported/{companySlug}/{skillSlug}/SKILL.md
|
||||
*
|
||||
* Collision handling: if a SKILL.md already exists, the skill is skipped (not overwritten).
|
||||
* Path safety: all segments are slugified to prevent directory traversal attacks.
|
||||
*/
|
||||
async function persistImportedSkills(
|
||||
projectRoot: string,
|
||||
skills: SkillManifestForImport[],
|
||||
companySlug: string | undefined,
|
||||
): Promise<SkillImportResult> {
|
||||
const result: SkillImportResult = {
|
||||
imported: [],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (!skills || skills.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Slugify company slug for directory safety
|
||||
const safeCompanySlug = companySlug
|
||||
? slugifyPathSegment(companySlug, "unknown-company")
|
||||
: "unknown-company";
|
||||
|
||||
const skillsBaseDir = join(projectRoot, "skills", "imported", safeCompanySlug);
|
||||
|
||||
const usedSlugs = new Set<string>();
|
||||
|
||||
for (const skill of skills) {
|
||||
const name = typeof skill.name === "string" && skill.name.trim().length > 0
|
||||
? skill.name.trim()
|
||||
: null;
|
||||
|
||||
if (!name) {
|
||||
result.errors.push({ name: String(skill.name ?? "?"), error: "Skill missing valid name" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
let skillSlug = slugifyPathSegment(name, "unnamed-skill");
|
||||
if (usedSlugs.has(skillSlug)) {
|
||||
let counter = 2;
|
||||
while (usedSlugs.has(`${skillSlug}-${counter}`)) {
|
||||
counter++;
|
||||
}
|
||||
skillSlug = `${skillSlug}-${counter}`;
|
||||
}
|
||||
usedSlugs.add(skillSlug);
|
||||
|
||||
const skillDir = join(skillsBaseDir, skillSlug);
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
|
||||
// Check for collision
|
||||
try {
|
||||
await access(skillPath);
|
||||
// File exists, skip
|
||||
result.skipped.push(name);
|
||||
continue;
|
||||
} catch {
|
||||
// File doesn't exist, proceed
|
||||
}
|
||||
|
||||
// Build frontmatter
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
name,
|
||||
schema: "agentcompanies/v1",
|
||||
kind: "skill",
|
||||
};
|
||||
|
||||
if (typeof skill.description === "string" && skill.description.trim()) {
|
||||
frontmatter.description = skill.description.trim();
|
||||
}
|
||||
if (typeof skill.slug === "string" && skill.slug.trim()) {
|
||||
frontmatter.slug = skill.slug.trim();
|
||||
}
|
||||
if (typeof skill.version === "string" && skill.version.trim()) {
|
||||
frontmatter.version = skill.version.trim();
|
||||
}
|
||||
if (typeof skill.license === "string" && skill.license.trim()) {
|
||||
frontmatter.license = skill.license.trim();
|
||||
}
|
||||
if (Array.isArray(skill.authors)) {
|
||||
const validAuthors = skill.authors.filter((a): a is string => typeof a === "string");
|
||||
if (validAuthors.length > 0) frontmatter.authors = validAuthors;
|
||||
}
|
||||
if (Array.isArray(skill.tags)) {
|
||||
const validTags = skill.tags.filter((t): t is string => typeof t === "string");
|
||||
if (validTags.length > 0) frontmatter.tags = validTags;
|
||||
}
|
||||
|
||||
// Build body from instructionBody
|
||||
const body = typeof skill.instructionBody === "string"
|
||||
? skill.instructionBody
|
||||
: `# ${name}\n\n<!-- Add skill instructions here. -->`;
|
||||
|
||||
try {
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
const content = toSkillMarkdown(frontmatter, body);
|
||||
await fsWriteFile(skillPath, content, "utf-8");
|
||||
result.imported.push({ name, path: `skills/imported/${safeCompanySlug}/${skillSlug}/SKILL.md` });
|
||||
} catch (err) {
|
||||
result.errors.push({ name, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/agents/import
|
||||
* Import agents from Agent Companies sources.
|
||||
*
|
||||
* Body modes (checked in order):
|
||||
* - { importSource: "companies.sh", companySlug: string, skipExisting?, dryRun? }
|
||||
* - { agents: AgentManifest[], skipExisting?, dryRun? }
|
||||
* - { source: string, skipExisting?, dryRun? } // server directory path
|
||||
* - { manifest: string, skipExisting?, dryRun? } // raw AGENTS.md content
|
||||
*/
|
||||
router.post("/agents/import", async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
agents,
|
||||
source,
|
||||
manifest,
|
||||
importSource,
|
||||
companySlug: importCompanySlug,
|
||||
selectedAgents,
|
||||
selectedSkills,
|
||||
skipExisting,
|
||||
dryRun,
|
||||
} = req.body ?? {};
|
||||
const {
|
||||
AgentStore,
|
||||
parseCompanyDirectory,
|
||||
parseCompanyArchive,
|
||||
parseSingleAgentManifest,
|
||||
prepareAgentCompaniesImport,
|
||||
AgentCompaniesParseError: _AgentCompaniesParseError,
|
||||
} = await import("@fusion/core");
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const existingAgents = await agentStore.listAgents();
|
||||
const existingNames = new Set(existingAgents.map((a) => a.name));
|
||||
const conversionOptions = {
|
||||
...(skipExisting ? { skipExisting: [...existingNames] } : {}),
|
||||
existingAgents,
|
||||
};
|
||||
|
||||
let pkg: {
|
||||
company?: { name?: string; slug?: string };
|
||||
agents: unknown[];
|
||||
teams: unknown[];
|
||||
projects: unknown[];
|
||||
tasks: unknown[];
|
||||
skills?: unknown[];
|
||||
};
|
||||
|
||||
if (Array.isArray(agents)) {
|
||||
pkg = {
|
||||
company: undefined,
|
||||
agents,
|
||||
teams: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
};
|
||||
} else if (typeof source === "string" && source.trim()) {
|
||||
const sourcePath = resolve(source);
|
||||
|
||||
const isArchive = sourcePath.endsWith(".tar.gz")
|
||||
|| sourcePath.endsWith(".tgz")
|
||||
|| sourcePath.endsWith(".zip");
|
||||
|
||||
if (isArchive) {
|
||||
try {
|
||||
await stat(sourcePath);
|
||||
} catch {
|
||||
throw badRequest(`source does not exist: ${sourcePath}`);
|
||||
}
|
||||
pkg = await parseCompanyArchive(sourcePath);
|
||||
} else {
|
||||
let sourceStat: import("node:fs").Stats;
|
||||
try {
|
||||
sourceStat = await stat(sourcePath);
|
||||
} catch {
|
||||
throw badRequest(`source does not exist: ${sourcePath}`);
|
||||
}
|
||||
if (sourceStat.isDirectory()) {
|
||||
pkg = parseCompanyDirectory(sourcePath);
|
||||
} else {
|
||||
throw badRequest("Source must be a server-side directory or archive path");
|
||||
}
|
||||
}
|
||||
} else if (typeof manifest === "string") {
|
||||
const { manifest: singleAgent } = parseSingleAgentManifest(manifest);
|
||||
pkg = {
|
||||
company: undefined,
|
||||
agents: [singleAgent],
|
||||
teams: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
};
|
||||
} else if (importSource === "companies.sh" && typeof importCompanySlug === "string") {
|
||||
// Import from companies.sh catalog
|
||||
if (!isValidCompanySlug(importCompanySlug)) {
|
||||
throw badRequest(`Invalid companies.sh slug: "${importCompanySlug}". Slugs must be lowercase alphanumeric with hyphens.`);
|
||||
}
|
||||
|
||||
// Fetch company info from companies.sh catalog API
|
||||
// Note: The per-company endpoint (/api/companies/:slug) returns HTML (SPA),
|
||||
// so we fetch the full list and filter by slug.
|
||||
const companyApiUrl = "https://companies.sh/api/companies";
|
||||
let companyInfo: { name: string; repo?: string; tagline?: string } | null = null;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
const response = await fetch(companyApiUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "fn-dashboard/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`companies.sh API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new Error("companies.sh API returned non-JSON content");
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
|
||||
// The API returns { items: [...] } — find the matching company by slug
|
||||
const items = Array.isArray(data.items)
|
||||
? data.items as Record<string, unknown>[]
|
||||
: Array.isArray(data)
|
||||
? data as Record<string, unknown>[]
|
||||
: [];
|
||||
|
||||
const match = items.find((item) => item.slug === importCompanySlug);
|
||||
if (!match) {
|
||||
throw badRequest(`Company not found: "${importCompanySlug}"`);
|
||||
}
|
||||
|
||||
const name = typeof match.name === "string" ? match.name : importCompanySlug;
|
||||
const repo = typeof match.repo === "string" ? match.repo : undefined;
|
||||
const tagline = typeof match.tagline === "string" ? match.tagline : undefined;
|
||||
|
||||
companyInfo = { name, repo, tagline };
|
||||
} catch (fetchErr) {
|
||||
const message = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
||||
if (fetchErr instanceof ApiError) throw fetchErr;
|
||||
if (message.includes("aborted")) {
|
||||
throw new Error("companies.sh request timed out");
|
||||
}
|
||||
throw badRequest(`Failed to fetch company "${importCompanySlug}": ${message}`);
|
||||
}
|
||||
|
||||
// Determine download URL from repo
|
||||
if (!companyInfo?.repo) {
|
||||
throw badRequest(`Company "${importCompanySlug}" has no repository URL`);
|
||||
}
|
||||
|
||||
// Parse the repo URL to determine the archive URL
|
||||
// Accept HTTPS GitHub URLs: https://github.com/owner/repo or shorthand: owner/repo
|
||||
const repoMatch = companyInfo.repo.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i)
|
||||
?? companyInfo.repo.match(/^([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_.-]+)$/);
|
||||
if (!repoMatch) {
|
||||
throw badRequest(`Unsupported repository URL format: ${companyInfo.repo}. Only GitHub HTTPS URLs and owner/repo shorthand are supported.`);
|
||||
}
|
||||
|
||||
const [, repoOwner, repoName] = repoMatch;
|
||||
// Use GitHub's archive API to get the default branch archive
|
||||
const archiveUrl = `https://github.com/${repoOwner}/${repoName}/archive/refs/heads/main.tar.gz`;
|
||||
|
||||
// Download and extract to temp directory
|
||||
let tempDir: string | null = null;
|
||||
try {
|
||||
tempDir = await mkdtemp(join(tmpdir(), `fn-agent-import-${importCompanySlug}-`));
|
||||
|
||||
// Download the archive
|
||||
const archivePath = join(tempDir, "archive.tar.gz");
|
||||
|
||||
// Download with 30-second timeout
|
||||
const downloadController = new AbortController();
|
||||
const downloadTimeout = setTimeout(() => downloadController.abort(), 30000);
|
||||
|
||||
let archiveResponse: globalThis.Response;
|
||||
try {
|
||||
archiveResponse = await fetch(archiveUrl, { signal: downloadController.signal });
|
||||
} finally {
|
||||
clearTimeout(downloadTimeout);
|
||||
}
|
||||
|
||||
let downloadResponse: globalThis.Response;
|
||||
if (archiveResponse.ok) {
|
||||
downloadResponse = archiveResponse;
|
||||
} else {
|
||||
// Try fallback branch (master) with its own timeout
|
||||
const fallbackController = new AbortController();
|
||||
const fallbackTimeout = setTimeout(() => fallbackController.abort(), 30000);
|
||||
|
||||
try {
|
||||
downloadResponse = await fetch(
|
||||
`https://github.com/${repoOwner}/${repoName}/archive/refs/heads/master.tar.gz`,
|
||||
{ signal: fallbackController.signal },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(fallbackTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadResponse.ok) {
|
||||
throw badRequest(`Failed to download repository archive: ${downloadResponse.status} ${downloadResponse.statusText}`);
|
||||
}
|
||||
if (!downloadResponse.body) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
await streamPipeline(
|
||||
Readable.fromWeb(downloadResponse.body as import("node:stream/web").ReadableStream),
|
||||
createWriteStream(archivePath),
|
||||
);
|
||||
|
||||
// Parse the downloaded archive directly to avoid requiring shell tar tools.
|
||||
pkg = await parseCompanyArchive(archivePath);
|
||||
|
||||
// Override company info if available from API
|
||||
if (companyInfo) {
|
||||
pkg.company = {
|
||||
name: companyInfo.name,
|
||||
slug: importCompanySlug,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
if (tempDir) {
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw badRequest("Provide one of: agents (array), source (path), manifest (string), or importSource + companySlug");
|
||||
}
|
||||
|
||||
const normalizeSelectionNames = (value: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const normalized = value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
const selectedAgentNameList = normalizeSelectionNames(selectedAgents);
|
||||
const selectedSkillNameList = normalizeSelectionNames(selectedSkills);
|
||||
|
||||
if (selectedAgentNameList) {
|
||||
const selectedAgentSet = new Set(selectedAgentNameList);
|
||||
pkg.agents = pkg.agents.filter((agent) => (
|
||||
typeof agent === "object"
|
||||
&& agent !== null
|
||||
&& typeof (agent as { name?: unknown }).name === "string"
|
||||
&& selectedAgentSet.has((agent as { name: string }).name)
|
||||
));
|
||||
}
|
||||
|
||||
if (selectedSkillNameList) {
|
||||
const selectedSkillSet = new Set(selectedSkillNameList);
|
||||
pkg.skills = (pkg.skills ?? []).filter((skill) => (
|
||||
typeof skill === "object"
|
||||
&& skill !== null
|
||||
&& typeof (skill as { name?: unknown }).name === "string"
|
||||
&& selectedSkillSet.has((skill as { name: string }).name)
|
||||
));
|
||||
}
|
||||
|
||||
const { items: importItems, result } = prepareAgentCompaniesImport(pkg as import("@fusion/core").AgentCompaniesPackage, conversionOptions);
|
||||
const companyName = pkg.company?.name ?? "Unknown";
|
||||
const companySlug = typeof pkg.company?.slug === "string" ? pkg.company.slug : undefined;
|
||||
const selectedSkillsCount = (pkg.skills ?? []).length;
|
||||
|
||||
if (importItems.length === 0 && selectedSkillsCount === 0 && result.errors.length === 0 && result.skipped.length === 0) {
|
||||
throw badRequest("No agents or skills found in manifest");
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
const agentPreview = importItems.map((item) => ({
|
||||
name: item.input.name,
|
||||
role: item.input.role,
|
||||
title: typeof item.input.title === "string" ? item.input.title : undefined,
|
||||
icon: typeof item.input.icon === "string" ? item.input.icon : undefined,
|
||||
reportsTo: item.reportsTo?.resolvedAgentId,
|
||||
instructionsText: typeof item.input.instructionsText === "string"
|
||||
? item.input.instructionsText.slice(0, 200) + (item.input.instructionsText.length > 200 ? "..." : "")
|
||||
: undefined,
|
||||
skills: Array.isArray(item.input.metadata?.skills)
|
||||
? item.input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const skillPreview = (pkg.skills ?? [])
|
||||
.filter((skill): skill is Record<string, unknown> => typeof skill === "object" && skill !== null)
|
||||
.map((skill) => ({
|
||||
name: typeof skill.name === "string" && skill.name.length > 0 ? skill.name : "Unnamed Skill",
|
||||
description: typeof skill.description === "string" ? skill.description : undefined,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
dryRun: true,
|
||||
companyName,
|
||||
...(companySlug ? { companySlug } : {}),
|
||||
agents: agentPreview,
|
||||
skills: skillPreview,
|
||||
created: result.created,
|
||||
skipped: result.skipped,
|
||||
errors: result.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const created: Array<{ id: string; name: string }> = [];
|
||||
const errors: Array<{ name: string; error: string }> = [...result.errors];
|
||||
const createdAgentIdsByManifestKey = new Map<string, string>();
|
||||
|
||||
for (const item of importItems) {
|
||||
if (!skipExisting && existingNames.has(item.input.name)) {
|
||||
errors.push({ name: item.input.name, error: "Agent with this name already exists" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const input = {
|
||||
...item.input,
|
||||
...(item.input.metadata ? { metadata: { ...item.input.metadata } } : {}),
|
||||
};
|
||||
|
||||
if (item.reportsTo?.deferredManifestKey) {
|
||||
const resolvedReportsTo = createdAgentIdsByManifestKey.get(item.reportsTo.deferredManifestKey);
|
||||
if (!resolvedReportsTo) {
|
||||
errors.push({
|
||||
name: item.input.name,
|
||||
error: `Could not resolve reportsTo reference "${item.reportsTo.raw}" because the manager was not created`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
input.reportsTo = resolvedReportsTo;
|
||||
} else if (item.reportsTo?.resolvedAgentId) {
|
||||
input.reportsTo = item.reportsTo.resolvedAgentId;
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await agentStore.createAgent(input);
|
||||
created.push({ id: agent.id, name: agent.name });
|
||||
createdAgentIdsByManifestKey.set(item.manifestKey, agent.id);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
errors.push({ name: item.input.name, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Persist package skills to project skills directory
|
||||
const projectRoot = scopedStore.getRootDir();
|
||||
const skillImportResult = await persistImportedSkills(
|
||||
projectRoot,
|
||||
(pkg.skills ?? []) as SkillManifestForImport[],
|
||||
companySlug,
|
||||
);
|
||||
|
||||
res.json({
|
||||
companyName,
|
||||
...(companySlug ? { companySlug } : {}),
|
||||
created,
|
||||
skipped: result.skipped,
|
||||
errors,
|
||||
skillsCount: (pkg.skills ?? []).length,
|
||||
skills: skillImportResult,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof Error && err.name === "AgentCompaniesParseError") {
|
||||
throw badRequest(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
// Handle AbortError from timed-out fetch calls
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw badRequest("Downloading company repository timed out after 30 seconds");
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const agentGenerationDiagnostics = createSessionDiagnostics("agent-generation");
|
||||
|
||||
/**
|
||||
* POST /api/agents/generate/start
|
||||
* Start a new agent generation session.
|
||||
* Body: { role: string }
|
||||
* Response: { sessionId, roleDescription }
|
||||
*/
|
||||
router.post("/agents/generate/start", async (req, res) => {
|
||||
try {
|
||||
const { role } = req.body as { role?: string };
|
||||
if (!role || typeof role !== "string") {
|
||||
throw badRequest("role is required and must be a string");
|
||||
}
|
||||
|
||||
const trimmedRole = role.trim();
|
||||
if (trimmedRole.length === 0) {
|
||||
throw badRequest("role must not be empty");
|
||||
}
|
||||
if (trimmedRole.length > 1000) {
|
||||
throw badRequest("role must not exceed 1000 characters");
|
||||
}
|
||||
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const session = await startAgentGeneration(ip, trimmedRole);
|
||||
|
||||
res.status(201).json({
|
||||
sessionId: session.id,
|
||||
roleDescription: session.roleDescription,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof AgentGenerationRateLimitError) {
|
||||
throw rateLimited(err.message);
|
||||
}
|
||||
agentGenerationDiagnostics.errorFromException("Error starting session", err, {
|
||||
operation: "generate-start",
|
||||
});
|
||||
rethrowAsApiError(err, "Failed to start agent generation session");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/generate/spec
|
||||
* Generate the agent specification for an existing session.
|
||||
* Body: { sessionId: string }
|
||||
* Response: { spec: AgentGenerationSpec }
|
||||
*/
|
||||
router.post("/agents/generate/spec", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId?: string };
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
const spec = await generateAgentSpec(sessionId, rootDir, settings.promptOverrides);
|
||||
res.json({ spec });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof AgentGenerationSessionNotFoundError) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
agentGenerationDiagnostics.errorFromException("Error generating spec", err, {
|
||||
operation: "generate-spec",
|
||||
});
|
||||
rethrowAsApiError(err, "Failed to generate agent specification");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/generate/:sessionId
|
||||
* Get the current state of an agent generation session.
|
||||
* Response: { session: AgentGenerationSession }
|
||||
*/
|
||||
router.get("/agents/generate/:sessionId", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const session = getAgentGenerationSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw notFound(`Session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
res.json({ session });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/generate/:sessionId
|
||||
* Cancel and clean up an agent generation session.
|
||||
* Response: { success: true }
|
||||
*/
|
||||
router.delete("/agents/generate/:sessionId", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
cleanupAgentGenerationSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/reflections/latest
|
||||
* Fetch the most recent reflection for an agent.
|
||||
* Must be registered before /agents/:id/reflections to avoid matching "latest" as a limit.
|
||||
* Response 200: AgentReflection | null — The most recent reflection or null
|
||||
* Response 404: { error: "Agent not found" } — When agent doesn't exist
|
||||
* { error: "No reflections found" } — When agent has no reflections
|
||||
*/
|
||||
router.get("/agents/:id/reflections/latest", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore, ReflectionStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
const reflectionStore = new ReflectionStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
await reflectionStore.init();
|
||||
|
||||
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!agentId) {
|
||||
throw badRequest("Agent id is required");
|
||||
}
|
||||
|
||||
// Validate the agent exists
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
const reflection = await reflectionStore.getLatestReflection(agentId);
|
||||
if (!reflection) {
|
||||
throw notFound("No reflections found");
|
||||
}
|
||||
|
||||
res.json(reflection);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/reflections
|
||||
* List reflection history for an agent.
|
||||
* Query params: limit (optional, default 50)
|
||||
* Response 200: AgentReflection[] — Array of reflections
|
||||
* Response 404: { error: "Agent not found" } — When agent doesn't exist
|
||||
*/
|
||||
router.get("/agents/:id/reflections", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore, ReflectionStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
const reflectionStore = new ReflectionStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
await reflectionStore.init();
|
||||
|
||||
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!agentId) {
|
||||
throw badRequest("Agent id is required");
|
||||
}
|
||||
|
||||
// Validate the agent exists
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
// Parse limit from query params (default 50)
|
||||
const limitParam = req.query.limit;
|
||||
const limit = limitParam ? parseInt(String(limitParam), 10) : 50;
|
||||
|
||||
const reflections = await reflectionStore.getReflections(agentId, limit);
|
||||
res.json(reflections);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/reflections
|
||||
* Trigger a manual reflection for an agent.
|
||||
* Response 201: AgentReflection — The created reflection
|
||||
* Response 404: { error: "Agent not found" } — When agent doesn't exist
|
||||
* Response 500: { error: message } — When reflection generation fails
|
||||
*/
|
||||
router.post("/agents/:id/reflections", async (req, res) => {
|
||||
try {
|
||||
const { store: taskStore } = await getProjectContext(req);
|
||||
const { AgentStore, ReflectionStore } = await import("@fusion/core");
|
||||
const { AgentReflectionService } = await import("@fusion/engine");
|
||||
const agentStore = new AgentStore({ rootDir: taskStore.getFusionDir() });
|
||||
const reflectionStore = new ReflectionStore({ rootDir: taskStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
await reflectionStore.init();
|
||||
|
||||
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!agentId) {
|
||||
throw badRequest("Agent id is required");
|
||||
}
|
||||
|
||||
// Validate the agent exists
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
// Create the reflection service and generate a reflection
|
||||
const reflectionService = new AgentReflectionService({
|
||||
agentStore,
|
||||
taskStore,
|
||||
reflectionStore,
|
||||
rootDir: taskStore.getRootDir(),
|
||||
});
|
||||
|
||||
const reflection = await reflectionService.generateReflection(agentId, "manual");
|
||||
if (!reflection) {
|
||||
throw internalError("Unable to generate reflection — insufficient history or AI unavailable");
|
||||
}
|
||||
|
||||
res.status(201).json(reflection);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/performance
|
||||
* Get aggregated performance summary for an agent.
|
||||
* Query params: windowMs (optional, default 7 days)
|
||||
* Response 200: AgentPerformanceSummary
|
||||
* Response 404: { error: "Agent not found" } — When agent doesn't exist
|
||||
*/
|
||||
router.get("/agents/:id/performance", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore, ReflectionStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
const reflectionStore = new ReflectionStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
await reflectionStore.init();
|
||||
|
||||
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!agentId) {
|
||||
throw badRequest("Agent id is required");
|
||||
}
|
||||
|
||||
// Validate the agent exists
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
// Parse windowMs from query params (default 7 days)
|
||||
const windowMsParam = req.query.windowMs;
|
||||
const windowMs = windowMsParam ? parseInt(String(windowMsParam), 10) : undefined;
|
||||
|
||||
const summary = await reflectionStore.getPerformanceSummary(agentId, { windowMs });
|
||||
res.json(summary);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/reflection-context
|
||||
* Get raw context for debugging agent reflections.
|
||||
* Response 200: { context: object } — The built reflection context
|
||||
* Response 404: { error: "Agent not found" } — When agent doesn't exist
|
||||
* Response 503: { error: "Reflection service not available" } — When engine not initialized
|
||||
*/
|
||||
router.get("/agents/:id/reflection-context", async (req, res) => {
|
||||
try {
|
||||
const { store: taskStore } = await getProjectContext(req);
|
||||
const { AgentStore, ReflectionStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: taskStore.getFusionDir() });
|
||||
const reflectionStore = new ReflectionStore({ rootDir: taskStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
await reflectionStore.init();
|
||||
|
||||
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!agentId) {
|
||||
throw badRequest("Agent id is required");
|
||||
}
|
||||
|
||||
// Validate the agent exists
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
// Check if AgentReflectionService is available
|
||||
let AgentReflectionService: typeof import("@fusion/engine").AgentReflectionService | undefined;
|
||||
try {
|
||||
const engine = await import("@fusion/engine");
|
||||
AgentReflectionService = engine.AgentReflectionService;
|
||||
} catch {
|
||||
res.status(503).json({ error: "Reflection service not available" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AgentReflectionService) {
|
||||
res.status(503).json({ error: "Reflection service not available" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the service and build the context
|
||||
const reflectionService = new AgentReflectionService({
|
||||
agentStore,
|
||||
taskStore,
|
||||
reflectionStore,
|
||||
rootDir: taskStore.getRootDir(),
|
||||
});
|
||||
|
||||
const context = await reflectionService.buildReflectionContext(agentId);
|
||||
res.json({ context });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Rating Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/ratings
|
||||
* Fetch ratings for an agent.
|
||||
* Query params: limit (number, default 50), category (string, optional)
|
||||
* Response 200: AgentRating[]
|
||||
*/
|
||||
router.get("/agents/:id/ratings", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
|
||||
const category = typeof req.query.category === "string" ? req.query.category : undefined;
|
||||
|
||||
const ratings = await agentStore.getRatings(req.params.id, { limit, category });
|
||||
res.json(ratings);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/ratings
|
||||
* Add a rating for an agent.
|
||||
* Body: { score: number, category?: string, comment?: string, runId?: string, taskId?: string, raterType?: string }
|
||||
* Response 201: AgentRating — The created rating
|
||||
* Response 400: { error: "score is required" } — When score is missing
|
||||
* { error: "score must be a number between 1 and 5" } — When score is invalid
|
||||
*/
|
||||
router.post("/agents/:id/ratings", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const { score, category, comment, runId, taskId, raterType } = req.body || {};
|
||||
|
||||
// Validate score
|
||||
if (score === undefined || score === null) {
|
||||
throw badRequest("score is required");
|
||||
}
|
||||
if (typeof score !== "number" || !Number.isFinite(score) || score < 1 || score > 5) {
|
||||
throw badRequest("score must be a number between 1 and 5");
|
||||
}
|
||||
|
||||
// Default raterType to "user" if not provided
|
||||
const resolvedRaterType = raterType || "user";
|
||||
|
||||
const rating = await agentStore.addRating(req.params.id, {
|
||||
score,
|
||||
category,
|
||||
comment,
|
||||
runId,
|
||||
taskId,
|
||||
raterType: resolvedRaterType,
|
||||
});
|
||||
|
||||
res.status(201).json(rating);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/ratings/summary
|
||||
* Fetch rating summary for an agent.
|
||||
* Response 200: AgentRatingSummary
|
||||
*/
|
||||
router.get("/agents/:id/ratings/summary", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const summary = await agentStore.getRatingSummary(req.params.id);
|
||||
res.json(summary);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/:id/ratings/:ratingId
|
||||
* Delete a specific rating.
|
||||
* Response 204: No Content
|
||||
*/
|
||||
router.delete("/agents/:id/ratings/:ratingId", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
await agentStore.deleteRating(req.params.ratingId);
|
||||
res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Generation Routes ──────────────────────────────────────────────
|
||||
|
||||
|
||||
}
|
||||
1394
packages/dashboard/src/routes/register-agent-runtime-routes.ts
Normal file
1394
packages/dashboard/src/routes/register-agent-runtime-routes.ts
Normal file
File diff suppressed because it is too large
Load Diff
181
packages/dashboard/src/routes/register-agent-skills-routes.ts
Normal file
181
packages/dashboard/src/routes/register-agent-skills-routes.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ApiError } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
export function registerAgentSkillsRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, options, getScopedStore, rethrowAsApiError } = ctx;
|
||||
|
||||
/**
|
||||
* GET /api/skills/discovered
|
||||
* List all discovered skills with their enabled state.
|
||||
* Query: projectId (optional) for multi-project context
|
||||
* Response: { skills: DiscoveredSkill[] }
|
||||
*/
|
||||
router.get("/skills/discovered", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const skillsAdapter = options?.skillsAdapter;
|
||||
|
||||
if (!skillsAdapter) {
|
||||
res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const skills = await skillsAdapter.discoverSkills(rootDir);
|
||||
|
||||
res.json({ skills });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to discover skills");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/skills/:id/content
|
||||
* Read the contents of a skill's SKILL.md file and list supplementary files.
|
||||
* Params: id (URL-encoded skill ID)
|
||||
* Query: projectId (optional) for multi-project context
|
||||
* Response: { content: SkillContent }
|
||||
* Error: 404 { error: string; code: "skill_not_found" | "adapter_not_configured" }
|
||||
*/
|
||||
router.get("/skills/:id/content", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const skillsAdapter = options?.skillsAdapter;
|
||||
|
||||
if (!skillsAdapter) {
|
||||
res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedSkillId = req.params.id as string;
|
||||
let skillId = encodedSkillId;
|
||||
try {
|
||||
skillId = decodeURIComponent(encodedSkillId);
|
||||
} catch {
|
||||
res.status(400).json({ error: "Invalid skill ID", code: "invalid_skill_id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const content = await skillsAdapter.readSkillContent(rootDir, skillId);
|
||||
|
||||
res.json({ content });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof Error && err.message.includes("Skill not found")) {
|
||||
res.status(404).json({ error: "Skill not found", code: "skill_not_found" });
|
||||
return;
|
||||
}
|
||||
if (err instanceof Error && err.message.includes("Invalid skill ID")) {
|
||||
res.status(400).json({ error: err.message, code: "invalid_skill_id" });
|
||||
return;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to read skill content");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/skills/execution
|
||||
* Toggle a skill's enabled/disabled state.
|
||||
* Body: { skillId: string; enabled: boolean }
|
||||
* Query: projectId (optional) for multi-project context
|
||||
* Response: { success: true; skillId: string; enabled: boolean; persistence: { scope: "project"; targetFile: string; settingsPath: string; pattern: string } }
|
||||
*/
|
||||
router.patch("/skills/execution", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const skillsAdapter = options?.skillsAdapter;
|
||||
|
||||
if (!skillsAdapter) {
|
||||
res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { skillId, enabled } = req.body as { skillId?: string; enabled?: boolean };
|
||||
|
||||
if (!skillId || typeof skillId !== "string") {
|
||||
res.status(400).json({ error: "skillId is required", code: "invalid_body" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof enabled !== "boolean") {
|
||||
res.status(400).json({ error: "enabled must be a boolean", code: "invalid_body" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const persistence = await skillsAdapter.toggleExecutionSkill(rootDir, { skillId, enabled });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
skillId,
|
||||
enabled,
|
||||
persistence: {
|
||||
scope: "project",
|
||||
targetFile: persistence.targetFile,
|
||||
settingsPath: persistence.settingsPath,
|
||||
pattern: persistence.pattern,
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof Error && err.message.includes("Invalid skill ID")) {
|
||||
res.status(400).json({ error: err.message, code: "invalid_skill_id" });
|
||||
return;
|
||||
}
|
||||
if (err instanceof Error && err.message.includes("Skill not found")) {
|
||||
res.status(404).json({ error: err.message, code: "skill_not_found" });
|
||||
return;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to toggle skill execution");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/skills/catalog
|
||||
* Fetch the skills.sh catalog with optional authentication.
|
||||
* Query:
|
||||
* - limit: number (default 20, max 100)
|
||||
* - q: optional search query
|
||||
* - projectId (optional) for multi-project context
|
||||
* Response: { entries: CatalogEntry[]; auth: { mode: string; tokenPresent: boolean; fallbackUsed: boolean } }
|
||||
* Error: 502 { error: string; code: "upstream_timeout"|"upstream_http_error"|"upstream_invalid_payload" }
|
||||
*/
|
||||
router.get("/skills/catalog", async (req, res) => {
|
||||
try {
|
||||
const skillsAdapter = options?.skillsAdapter;
|
||||
|
||||
if (!skillsAdapter) {
|
||||
res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const limitStr = typeof req.query.limit === "string" ? req.query.limit : "20";
|
||||
const limit = Math.min(Math.max(1, parseInt(limitStr, 10) || 20), 100);
|
||||
const query = typeof req.query.q === "string" ? req.query.q : undefined;
|
||||
|
||||
const result = await skillsAdapter.fetchCatalog({ limit, query });
|
||||
|
||||
// Check if result is an upstream error
|
||||
if ("code" in result) {
|
||||
res.status(502).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to fetch skills catalog");
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user