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:
Fusion
2026-04-26 03:06:05 -07:00
committed by gsxdsm
parent db9f4ba57e
commit b0c6b14022
7 changed files with 3445 additions and 3283 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View 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);
}

View File

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

View File

@@ -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 ──────────────────────────────────────────────
}

File diff suppressed because it is too large Load Diff

View 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");
}
});
}