feat(FN-2538): merge fusion/fn-2538

This commit is contained in:
gsxdsm
2026-04-25 18:06:52 -07:00
parent 78b09711a2
commit 2356cbbe08
13 changed files with 1401 additions and 1338 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
# Dashboard API route registrars
`packages/dashboard/src/routes.ts` remains the single public entrypoint (`createApiRoutes(store, options)`), but route definitions are registered by domain modules in this directory.
## Shared context contract
All registrars receive `ApiRoutesContext` from `./types.ts`, built by `createApiRoutesContext()` in `./context.ts`.
The context centralizes cross-cutting dependencies so registrars preserve behavior without re-implementing plumbing:
- Request/project scoping: `getProjectIdFromRequest`, `getScopedStore`, `getProjectContext`
- Engine-aware fallback behavior for project-bound and root-store APIs
- Runtime loggers and diagnostics emitters (`runtimeLogger`, `planningLogger`, `proxyLogger`, `chatLogger`)
- Proxy/auth/audit helpers (`proxyToRemoteNode`, `emitRemoteRouteDiagnostic`, `emitAuthSyncAuditLog`)
- Automation/routine resolvers and scope parsing helpers
- Shared error normalization (`rethrowAsApiError`)
## Registrar module map
- `register-settings-memory.ts` — settings, memory backend, memory file APIs
- `register-tasks.ts` — tasks, comments, documents, activity, task lifecycle operations
- `register-planning-chat.ts` — planning sessions, subtasks, chat session routes
- `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-plugins-automation.ts` — plugin CRUD, automation, routines/webhooks
- `register-proxy.ts` — remote-node proxy forwarding and SSE proxy routes
## Ordering rules (critical)
Express matches in registration order. Keep registrar and in-registrar route ordering stable:
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**
If adding a new endpoint, place it in the domain registrar and verify it does not shadow existing handlers.
## Integration mounts that stay in `routes.ts`
These routers remain mounted directly by the orchestrator and must keep their current prefixes/options wiring:
- `createMissionRouter``/api/missions`
- `createRoadmapRouter``/api/roadmaps`
- `createInsightsRouter``/api/insights`
- `createDevServerRouter``/api/dev-server`
Do not re-home these mounts without explicit migration and regression coverage.

View File

@@ -0,0 +1,405 @@
import { Router, type Request, type Response } from "express";
import { resolve, sep } from "node:path";
import type { TaskStore } from "@fusion/core";
import type { ServerOptions } from "../server.js";
import { badRequest, notFound, ApiError, internalError } from "../api-error.js";
import { getOrCreateProjectStore } from "../project-store-resolver.js";
import { createRuntimeLogger } from "../runtime-logger.js";
import type {
ApiRoutesContext,
AuthSyncAuditLogInput,
ProjectContext,
RemoteRouteDiagnosticInput,
ScopeValue,
} from "./types.js";
function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
if (error instanceof ApiError) {
throw error;
}
if (error instanceof Error) {
throw internalError(error.message || fallbackMessage);
}
throw internalError(fallbackMessage);
}
function classifyRemoteRouteError(error: unknown): {
classification: "timeout" | "transport" | "unexpected";
errorClass: string;
errorMessage: string;
} {
const fallbackMessage = String(error);
if (error instanceof Error) {
const errorClass = error.constructor?.name || error.name || "Error";
const errorMessage = error.message || fallbackMessage;
if (error.name === "AbortError") {
return { classification: "timeout", errorClass, errorMessage };
}
if (error instanceof TypeError) {
return { classification: "transport", errorClass, errorMessage };
}
return { classification: "unexpected", errorClass, errorMessage };
}
if ((error as { name?: unknown } | null)?.name === "AbortError") {
return { classification: "timeout", errorClass: "AbortError", errorMessage: fallbackMessage };
}
return {
classification: "unexpected",
errorClass: typeof error,
errorMessage: fallbackMessage,
};
}
export function createApiRoutesContext(store: TaskStore, options?: ServerOptions): ApiRoutesContext {
const router = Router();
const runtimeLogger = options?.runtimeLogger?.child("routes") ?? createRuntimeLogger("routes");
const planningLogger = runtimeLogger.child("planning");
const proxyLogger = runtimeLogger.child("proxy");
const chatLogger = runtimeLogger.child("chat");
function prioritizeProjectsForCurrentDirectory<T extends { path: string }>(projects: T[]): T[] {
const cwd = resolve(process.cwd());
const rankProject = (projectPath: string): number => {
const normalizedProjectPath = resolve(projectPath);
if (normalizedProjectPath === cwd) {
return Number.MAX_SAFE_INTEGER;
}
const prefix = normalizedProjectPath.endsWith(sep)
? normalizedProjectPath
: `${normalizedProjectPath}${sep}`;
if (!cwd.startsWith(prefix)) {
return -1;
}
return normalizedProjectPath.length;
};
return [...projects].sort((a, b) => rankProject(b.path) - rankProject(a.path));
}
function getProjectIdFromRequest(req: Request): string | undefined {
if (req.query && typeof req.query.projectId === "string" && req.query.projectId.length > 0) {
return req.query.projectId;
}
if (req.body && typeof req.body.projectId === "string" && req.body.projectId.length > 0) {
return req.body.projectId;
}
return undefined;
}
async function getScopedStore(req: Request): Promise<TaskStore> {
const projectId = getProjectIdFromRequest(req);
if (!projectId) return store;
return getOrCreateProjectStore(projectId);
}
async function getProjectContext(req: Request): Promise<ProjectContext> {
const projectId = getProjectIdFromRequest(req);
const engineManager = options?.engineManager;
if (projectId && engineManager) {
let engine = engineManager.getEngine(projectId);
if (!engine) {
try {
engine = await engineManager.ensureEngine(projectId);
} catch {
// fall through
}
}
if (engine) {
return { store: engine.getTaskStore(), engine, projectId };
}
}
const scopedStore = await getScopedStore(req);
return { store: scopedStore, engine: undefined, projectId };
}
function emitRemoteRouteDiagnostic(input: RemoteRouteDiagnosticInput): void {
const logger = runtimeLogger.child("remote-route").child(input.route);
const level = input.level ?? "error";
const context: Record<string, unknown> = {
...(input.nodeId !== undefined ? { nodeId: input.nodeId } : {}),
...(input.upstreamPath !== undefined ? { upstreamPath: input.upstreamPath } : {}),
...(input.stage !== undefined ? { stage: input.stage } : {}),
...(input.operationStage !== undefined ? { operationStage: input.operationStage } : {}),
...(input.context ?? {}),
};
if (input.error !== undefined) {
const classified = classifyRemoteRouteError(input.error);
context.transportClassification = classified.classification;
context.errorClass = classified.errorClass;
context.errorMessage = classified.errorMessage;
}
if (level === "info") {
logger.info(input.message, context);
return;
}
if (level === "warn") {
logger.warn(input.message, context);
return;
}
logger.error(input.message, context);
}
function emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void {
const logger = runtimeLogger.child("settings-sync").child("auth");
const level = input.level ?? "info";
const providerNames = input.providerNames.filter((provider) => typeof provider === "string");
const context: Record<string, unknown> = {
operation: input.operation,
direction: input.direction,
route: input.route,
providerNames,
providerCount: providerNames.length,
...(input.sourceNodeId !== undefined ? { sourceNodeId: input.sourceNodeId } : {}),
...(input.targetNodeId !== undefined ? { targetNodeId: input.targetNodeId } : {}),
};
if (level === "warn") {
logger.warn("Auth sync diagnostic event", context);
return;
}
if (level === "error") {
logger.error("Auth sync diagnostic event", context);
return;
}
logger.info("Auth sync diagnostic event", context);
}
async function proxyToRemoteNode(
req: Request,
res: Response,
remotePath: string,
proxyOptions?: { timeoutMs?: number },
): Promise<void> {
const nodeId = req.params.nodeId as string;
const timeoutMs = proxyOptions?.timeoutMs ?? 10_000;
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
try {
await central.init();
const node = await central.getNode(nodeId);
if (!node) throw notFound("Node not found");
if (node.type === "local") throw badRequest("Cannot proxy to local node");
if (!node.url) throw badRequest("Node has no URL configured");
const parsedUrl = new URL(req.url, "http://localhost");
const queryString = parsedUrl.search;
const targetPath = `/api${remotePath}${queryString}`;
const targetUrl = new URL(targetPath, node.url).toString();
const headers: Record<string, string> = {};
if (node.apiKey) {
headers.Authorization = `Bearer ${node.apiKey}`;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(targetUrl, { headers, signal: controller.signal });
clearTimeout(timeout);
const hopByHopHeaders = new Set([
"transfer-encoding",
"connection",
"keep-alive",
"upgrade",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
]);
response.headers.forEach((value, key) => {
if (!hopByHopHeaders.has(key.toLowerCase())) {
res.setHeader(key, value);
}
});
res.status(response.status);
if (!response.body) {
res.end();
return;
}
const { Readable } = await import("node:stream");
const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream);
nodeStream.on("data", (chunk: Buffer) => {
res.write(chunk);
});
nodeStream.on("end", () => {
res.end();
});
nodeStream.on("error", (err: Error) => {
proxyLogger.error(`Stream error for node ${nodeId}`, { error: err.message });
if (!res.writableEnded) {
res.end();
}
});
} catch (err: unknown) {
if (res.headersSent) {
return;
}
if (err instanceof Error && err.name === "AbortError") {
res.status(504).json({ error: "Remote node timeout" });
} else if (err instanceof TypeError) {
res.status(502).json({ error: "Remote node unreachable" });
} else if (err instanceof ApiError) {
throw err;
} else if (err instanceof Error && err.message) {
throw new ApiError(500, err.message);
} else {
throw new ApiError(500, "Proxy request failed");
}
} finally {
await central.close();
}
}
function parseScopeParam(req: Request): ScopeValue | undefined {
const rawScope =
(typeof req.query.scope === "string" ? req.query.scope : undefined) ??
(req.body && typeof req.body.scope === "string" ? req.body.scope : undefined);
if (rawScope === undefined || rawScope === "") {
return undefined;
}
if (rawScope !== "global" && rawScope !== "project") {
throw new ApiError(400, `Invalid scope value "${rawScope}". Must be "global" or "project".`);
}
return rawScope;
}
function resolveAutomationStore(req: Request, scope: ScopeValue | undefined): import("@fusion/core").AutomationStore {
const projectId = getProjectIdFromRequest(req);
const engineManager = options?.engineManager;
if (scope === "global" || scope === undefined) {
const defaultStore = options?.automationStore;
if (!defaultStore) {
throw new ApiError(503, "Automation store not available");
}
return defaultStore;
}
if (projectId && engineManager) {
const engine = engineManager.getEngine(projectId);
if (engine) {
const engineStore = engine.getAutomationStore();
if (engineStore) {
return engineStore;
}
}
}
const defaultStore = options?.automationStore;
if (!defaultStore) {
throw new ApiError(503, "Automation store not available");
}
return defaultStore;
}
function resolveRoutineStore(req: Request, scope: ScopeValue | undefined): import("@fusion/core").RoutineStore {
const projectId = getProjectIdFromRequest(req);
const engineManager = options?.engineManager;
if (scope === "global" || scope === undefined) {
const defaultStore = options?.routineStore;
if (!defaultStore) {
throw new ApiError(503, "Routine store not available");
}
return defaultStore;
}
if (projectId && engineManager) {
const engine = engineManager.getEngine(projectId);
if (engine) {
const engineStore = engine.getRoutineStore();
if (engineStore) {
return engineStore;
}
}
}
const defaultStore = options?.routineStore;
if (!defaultStore) {
throw new ApiError(503, "Routine store not available");
}
return defaultStore;
}
function resolveRoutineRunner(req: Request, scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]> {
const projectId = getProjectIdFromRequest(req);
const engineManager = options?.engineManager;
if (scope === "project" && projectId && engineManager) {
const engine = engineManager.getEngine(projectId);
if (engine) {
const engineRunner = engine.getRoutineRunner();
if (engineRunner) {
return {
triggerManual: engineRunner.triggerManual.bind(engineRunner),
triggerWebhook: engineRunner.triggerWebhook.bind(engineRunner),
};
}
}
}
const runner = options?.routineRunner;
if (!runner) {
throw new ApiError(503, "Routine execution not available");
}
return runner;
}
return {
router,
store,
options,
runtimeLogger,
planningLogger,
proxyLogger,
chatLogger,
prioritizeProjectsForCurrentDirectory,
getProjectIdFromRequest,
getScopedStore,
getProjectContext,
emitRemoteRouteDiagnostic,
emitAuthSyncAuditLog,
proxyToRemoteNode,
parseScopeParam,
resolveAutomationStore,
resolveRoutineStore,
resolveRoutineRunner,
rethrowAsApiError,
};
}

View File

@@ -0,0 +1,5 @@
import type { ApiRoutesContext } from "./types.js";
export function registerAgentsProjectsNodesRoutes(_ctx: ApiRoutesContext): void {
// Step scaffold: route extraction lands in subsequent steps.
}

View File

@@ -0,0 +1,5 @@
import type { ApiRoutesContext } from "./types.js";
export function registerFilesTerminalWorkspaceRoutes(_ctx: ApiRoutesContext): void {
// Step scaffold: route extraction lands in subsequent steps.
}

View File

@@ -0,0 +1,5 @@
import type { ApiRoutesContext } from "./types.js";
export function registerGitGitHubRoutes(_ctx: ApiRoutesContext): void {
// Step scaffold: route extraction lands in subsequent steps.
}

View File

@@ -0,0 +1,390 @@
import type { Request } from "express";
import { MessageStore, type MessageType, type ParticipantType, validateMessageMetadata } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { getTerminalService } from "../terminal-service.js";
import type { ApiRoutesContext } from "./types.js";
export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
const { router, options, getProjectContext, rethrowAsApiError } = ctx;
// ── Scripts API ──────────────────────────────────────────────────────────
/**
* GET /api/scripts
* Fetch all saved scripts.
* Returns: Record<string, string> (name -> command)
*/
router.get("/scripts", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
res.json(settings.scripts ?? {});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/scripts
* Add or update a script.
* Body: { name: string, command: string }
* Returns: Record<string, string> (updated scripts)
*/
router.post("/scripts", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { name, command } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
throw badRequest("name is required");
}
if (command === undefined || typeof command !== "string") {
throw badRequest("command is required");
}
const settings = await scopedStore.getSettings();
const scripts = {
...(settings.scripts ?? {}),
[name.trim()]: command.trim(),
};
await scopedStore.updateSettings({ scripts });
res.json(scripts);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* DELETE /api/scripts/:name
* Remove a script.
* Returns: Record<string, string> (updated scripts)
*/
router.delete("/scripts/:name", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { name } = req.params;
const settings = await scopedStore.getSettings();
const scripts = { ...(settings.scripts ?? {}) };
delete scripts[name];
await scopedStore.updateSettings({ scripts });
res.json(scripts);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/scripts/:name/run
* Execute a saved script by name using terminal service.
* Body: { args?: string[] } - Optional arguments to append to the command
* Returns: { sessionId: string, command: string }
*/
router.post("/scripts/:name/run", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const scriptName = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name;
if (!scriptName) {
throw badRequest("Script name is required");
}
if (!/^[a-zA-Z0-9_-]+$/.test(scriptName)) {
throw badRequest("Script name must contain only alphanumeric characters, hyphens, and underscores (no spaces)");
}
const settings = await scopedStore.getSettings();
const currentScripts = settings.scripts ?? {};
if (currentScripts[scriptName] === undefined) {
throw notFound(`Script '${scriptName}' not found`);
}
const baseCommand = currentScripts[scriptName];
const { args } = req.body ?? {};
if (args !== undefined && !Array.isArray(args)) {
throw badRequest("args must be an array of strings");
}
if (args && !args.every((a: unknown) => typeof a === "string")) {
throw badRequest("args must be an array of strings");
}
let fullCommand = baseCommand;
if (args && args.length > 0) {
const escapedArgs = args.map((arg: unknown) => {
const str = String(arg);
if (str.includes('"') || str.includes("$") || str.includes("`")) {
return `'${str.replace(/'/g, "'\\''")}'`;
}
return `"${str}"`;
});
fullCommand = `${baseCommand} ${escapedArgs.join(" ")}`;
}
const terminalService = getTerminalService(scopedStore.getRootDir());
const result = await terminalService.createSession({
cwd: scopedStore.getRootDir(),
});
if (!result.success) {
const statusByCode = {
max_sessions: 503,
invalid_shell: 400,
pty_load_failed: 503,
pty_spawn_failed: 500,
} as const;
const status = result.code ? (statusByCode[result.code] ?? 500) : 500;
throw new ApiError(status, result.error || "Failed to create terminal session");
}
const sessionId = result.session.id;
terminalService.writeInput(sessionId, `${fullCommand}\n`);
res.status(201).json({
sessionId,
command: fullCommand,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// ── Messaging Routes ──────────────────────────────────────────────────
/** Cache of MessageStore instances keyed by rootDir */
const messageStoreCache = new Map<string, MessageStore>();
async function getMessageStore(req: Request): Promise<MessageStore> {
const { store: scopedStore, engine, projectId } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
// Prefer the runtime's MessageStore when available so routes and SSE share
// the same EventEmitter instance (required for live mailbox updates).
const runtimeMessageStore =
engine?.getMessageStore() ?? (!projectId ? options?.engine?.getMessageStore() : undefined);
if (runtimeMessageStore) {
messageStoreCache.set(rootDir, runtimeMessageStore);
return runtimeMessageStore;
}
let msgStore = messageStoreCache.get(rootDir);
if (!msgStore) {
const db = scopedStore.getDatabase();
msgStore = new MessageStore(db);
messageStoreCache.set(rootDir, msgStore);
}
return msgStore;
}
const VALID_MESSAGE_TYPES: MessageType[] = ["agent-to-agent", "agent-to-user", "user-to-agent", "system"];
const VALID_PARTICIPANT_TYPES: ParticipantType[] = ["agent", "user", "system"];
const DASHBOARD_USER_ID = "dashboard";
router.get("/messages/inbox", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const filter = {
limit: parseInt(req.query.limit as string) || 20,
offset: parseInt(req.query.offset as string) || 0,
read: req.query.unreadOnly === "true" ? false : undefined,
type: req.query.type as MessageType | undefined,
};
const messages = await msgStore.getInbox(DASHBOARD_USER_ID, "user", filter);
const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user");
res.json({ messages, total: messages.length, unreadCount: mailbox.unreadCount });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/messages/outbox", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const filter = {
limit: parseInt(req.query.limit as string) || 20,
offset: parseInt(req.query.offset as string) || 0,
type: req.query.type as MessageType | undefined,
};
const messages = await msgStore.getOutbox(DASHBOARD_USER_ID, "user", filter);
res.json({ messages, total: messages.length });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/messages/unread-count", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user");
res.json({ unreadCount: mailbox.unreadCount });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
// IMPORTANT: Must be registered before /messages/:id to avoid path conflicts.
router.post("/messages/read-all", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const count = await msgStore.markAllAsRead(DASHBOARD_USER_ID, "user");
res.json({ markedAsRead: count });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.post("/messages", async (req, res) => {
try {
const { toId, toType, content, type, metadata } = req.body;
if (!toId || typeof toId !== "string") {
throw badRequest("toId is required");
}
if (!toType || !VALID_PARTICIPANT_TYPES.includes(toType)) {
throw badRequest(`toType must be one of: ${VALID_PARTICIPANT_TYPES.join(", ")}`);
}
if (!content || typeof content !== "string" || content.length === 0 || content.length > 2000) {
throw badRequest("content is required and must be 1-2000 characters");
}
if (!type || !VALID_MESSAGE_TYPES.includes(type)) {
throw badRequest(`type must be one of: ${VALID_MESSAGE_TYPES.join(", ")}`);
}
if (metadata !== undefined && (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))) {
throw badRequest("metadata must be an object");
}
try {
validateMessageMetadata(metadata);
} catch (err: unknown) {
throw badRequest(err instanceof Error ? err.message : "metadata.replyTo is invalid");
}
const msgStore = await getMessageStore(req);
const message = await msgStore.sendMessage({
fromId: DASHBOARD_USER_ID,
fromType: "user",
toId,
toType,
content,
type,
metadata,
});
res.status(201).json(message);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/messages/conversation/:participantType/:participantId", async (req, res) => {
try {
const { participantType, participantId } = req.params;
if (!VALID_PARTICIPANT_TYPES.includes(participantType as ParticipantType)) {
throw badRequest(`participantType must be one of: ${VALID_PARTICIPANT_TYPES.join(", ")}`);
}
const msgStore = await getMessageStore(req);
const messages = await msgStore.getConversation(
{ id: DASHBOARD_USER_ID, type: "user" },
{ id: participantId, type: participantType as ParticipantType },
);
res.json(messages);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/messages/:id", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const message = await msgStore.getMessage(req.params.id);
if (!message) {
throw notFound("Message not found");
}
res.json(message);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.post("/messages/:id/read", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const message = await msgStore.markAsRead(req.params.id);
res.json(message);
} 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));
}
rethrowAsApiError(err);
}
});
router.delete("/messages/:id", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
await msgStore.deleteMessage(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));
}
rethrowAsApiError(err);
}
});
router.get("/agents/:id/mailbox", async (req, res) => {
try {
const msgStore = await getMessageStore(req);
const agentId = req.params.id;
const mailbox = await msgStore.getMailbox(agentId, "agent");
const inbox = await msgStore.getInbox(agentId, "agent");
const outbox = await msgStore.getOutbox(agentId, "agent");
res.json({ ...mailbox, messages: inbox, inbox, outbox });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
}

View File

@@ -0,0 +1,5 @@
import type { ApiRoutesContext } from "./types.js";
export function registerPlanningChatRoutes(_ctx: ApiRoutesContext): void {
// Step scaffold: route extraction lands in subsequent steps.
}

View File

@@ -0,0 +1,5 @@
import type { ApiRoutesContext } from "./types.js";
export function registerPluginsAutomationRoutes(_ctx: ApiRoutesContext): void {
// Step scaffold: route extraction lands in subsequent steps.
}

View File

@@ -0,0 +1,392 @@
import { type Request, type Response } from "express";
import { ApiError } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
export function registerProxyRoutes(ctx: ApiRoutesContext): void {
const { router, store, proxyToRemoteNode, emitRemoteRouteDiagnostic, rethrowAsApiError } = ctx;
/** GET /api/proxy/:nodeId/health — Forward health check to remote node */
router.get("/proxy/:nodeId/health", async function (req, res) {
try {
await proxyToRemoteNode(req, res, "/health");
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/** GET /api/proxy/:nodeId/projects — Forward projects list to remote node */
router.get("/proxy/:nodeId/projects", async function (req, res) {
try {
await proxyToRemoteNode(req, res, "/projects");
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/** GET /api/proxy/:nodeId/tasks — Forward tasks list to remote node (forwards projectId, q query params) */
router.get("/proxy/:nodeId/tasks", async function (req, res) {
try {
await proxyToRemoteNode(req, res, "/tasks");
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/** GET /api/proxy/:nodeId/project-health — Forward project health to remote node (forwards projectId query param) */
router.get("/proxy/:nodeId/project-health", async function (req, res) {
try {
await proxyToRemoteNode(req, res, "/project-health");
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* GET /api/proxy/:nodeId/events — SSE proxy to remote node events stream.
* Uses a 30-second timeout since SSE connections are long-lived.
* Handles client disconnect gracefully.
*/
router.get("/proxy/:nodeId/events", async function (req, res) {
const nodeId = req.params.nodeId as string;
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
try {
await central.init();
const node = await central.getNode(nodeId);
if (!node) {
res.status(404).json({ error: "Node not found" });
return;
}
if (node.type === "local") {
res.status(400).json({ error: "Cannot proxy to local node" });
return;
}
if (!node.url) {
res.status(400).json({ error: "Node has no URL configured" });
return;
}
const parsedUrl = new URL(req.url, "http://localhost");
const queryString = parsedUrl.search;
const upstreamPath = `/api/events${queryString}`;
const targetUrl = new URL(upstreamPath, node.url).toString();
const headers: Record<string, string> = {};
if (node.apiKey) {
headers["Authorization"] = `Bearer ${node.apiKey}`;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
const response = await fetch(targetUrl, {
headers,
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
res.status(response.status).json({ error: "Remote node events unavailable" });
return;
}
if (!response.body) {
res.status(502).json({ error: "Remote node unreachable" });
return;
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
res.write(": connected\n\n");
const { Readable } = await import("node:stream");
const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream);
let destroyed = false;
req.on("close", () => {
if (!destroyed) {
destroyed = true;
emitRemoteRouteDiagnostic({
route: "proxy-sse",
message: "Closing SSE proxy stream after client disconnect",
nodeId,
upstreamPath,
stage: "client-disconnect",
level: "info",
});
controller.abort();
nodeStream.destroy();
}
});
nodeStream.on("data", (chunk: Buffer) => {
if (!res.writableEnded) {
res.write(chunk);
}
});
nodeStream.on("end", () => {
if (!res.writableEnded) {
res.end();
}
});
nodeStream.on("error", (err: Error) => {
emitRemoteRouteDiagnostic({
route: "proxy-sse",
message: "SSE proxy stream error",
nodeId,
upstreamPath,
stage: "upstream-stream",
error: err,
});
if (!res.writableEnded) {
res.end();
}
});
} catch (err: unknown) {
const parsedUrl = new URL(req.url, "http://localhost");
const queryString = parsedUrl.search;
const upstreamPath = `/api/events${queryString}`;
if (err instanceof Error && err.name === "AbortError") {
emitRemoteRouteDiagnostic({
route: "proxy-sse",
message: "SSE proxy request timed out",
nodeId,
upstreamPath,
stage: "fetch",
error: err,
level: "warn",
});
if (!res.headersSent) {
res.status(504).json({ error: "Remote node timeout" });
} else if (!res.writableEnded) {
res.end();
}
} else if (err instanceof TypeError) {
emitRemoteRouteDiagnostic({
route: "proxy-sse",
message: "SSE proxy transport failure",
nodeId,
upstreamPath,
stage: "fetch",
error: err,
level: "warn",
});
if (!res.headersSent) {
res.status(502).json({ error: "Remote node unreachable" });
} else if (!res.writableEnded) {
res.end();
}
} else {
emitRemoteRouteDiagnostic({
route: "proxy-sse",
message: "SSE proxy unexpected failure",
nodeId,
upstreamPath,
stage: "fetch",
error: err,
});
if (!res.headersSent) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
} else if (!res.writableEnded) {
res.end();
}
}
} finally {
await central.close();
}
});
/**
* Generic wildcard proxy route — forwards any HTTP request to a remote node.
* Matches /api/proxy/:nodeId/*
*/
router.all("/proxy/:nodeId/*splat", async (req: Request, res: Response) => {
const nodeId = req.params.nodeId as string;
const splat = req.params.splat as string | string[];
const remainingPath = Array.isArray(splat) ? splat.join("/") : splat;
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore(store.getFusionDir());
try {
await central.init();
const node = await central.getNode(nodeId);
if (!node) {
res.status(404).json({ error: "Node not found" });
return;
}
if (!node.url) {
res.status(400).json({ error: "Node has no URL" });
return;
}
const parsedUrl = new URL(req.url ?? "/", "http://localhost");
const queryString = parsedUrl.search;
const targetPath = `/${remainingPath}${queryString}`;
const targetUrl = new URL(targetPath, node.url).toString();
const headers: Record<string, string> = {};
if (typeof req.headers["content-type"] === "string") {
headers["Content-Type"] = req.headers["content-type"];
}
if (node.apiKey) {
headers["Authorization"] = `Bearer ${node.apiKey}`;
}
let body: Buffer | undefined;
if (req.method !== "GET" && req.method !== "HEAD") {
const chunks: Buffer[] = [];
if (req.rawBody && req.rawBody.length > 0) {
body = req.rawBody;
} else {
await new Promise<void>((resolve, reject) => {
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", resolve);
req.on("error", reject);
});
if (chunks.length > 0) {
body = Buffer.concat(chunks);
}
}
}
const response = await fetch(targetUrl, {
method: req.method,
headers,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: body as any,
signal: AbortSignal.timeout(30_000),
});
const hopByHopHeaders = new Set([
"connection",
"keep-alive",
"transfer-encoding",
"upgrade",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
]);
response.headers.forEach((value, key) => {
if (!hopByHopHeaders.has(key.toLowerCase())) {
res.setHeader(key, value);
}
});
res.status(response.status);
if (!response.body) {
res.end();
return;
}
const { Readable } = await import("node:stream");
const nodeStream = Readable.fromWeb(response.body as import("node:stream/web").ReadableStream);
nodeStream.on("data", (chunk: Buffer) => {
res.write(chunk);
});
nodeStream.on("end", () => {
res.end();
});
nodeStream.on("error", (err: Error) => {
emitRemoteRouteDiagnostic({
route: "proxy-wildcard",
message: "Wildcard proxy stream error",
nodeId,
upstreamPath: targetPath,
stage: "upstream-stream",
error: err,
});
if (!res.writableEnded) {
res.end();
}
});
} catch (err: unknown) {
const parsedUrl = new URL(req.url ?? "/", "http://localhost");
const queryString = parsedUrl.search;
const targetPath = `/${remainingPath}${queryString}`;
const errorObj = err as { name?: string } | null;
const isAbortError = errorObj?.name === "AbortError";
if (isAbortError) {
emitRemoteRouteDiagnostic({
route: "proxy-wildcard",
message: "Wildcard proxy request timed out",
nodeId,
upstreamPath: targetPath,
stage: "fetch",
error: err,
level: "warn",
});
if (res.headersSent) {
return;
}
res.status(504).json({ error: "Gateway Timeout" });
} else if (err instanceof TypeError) {
emitRemoteRouteDiagnostic({
route: "proxy-wildcard",
message: "Wildcard proxy transport failure",
nodeId,
upstreamPath: targetPath,
stage: "fetch",
error: err,
level: "warn",
});
if (res.headersSent) {
return;
}
res.status(502).json({ error: "Bad Gateway" });
} else {
emitRemoteRouteDiagnostic({
route: "proxy-wildcard",
message: "Wildcard proxy unexpected failure",
nodeId,
upstreamPath: targetPath,
stage: "fetch",
error: err,
});
if (res.headersSent) {
return;
}
res.status(502).json({ error: "Bad Gateway" });
}
} finally {
await central.close();
}
});
}

View File

@@ -0,0 +1,5 @@
import type { ApiRoutesContext } from "./types.js";
export function registerSettingsMemoryRoutes(_ctx: ApiRoutesContext): void {
// Step scaffold: route extraction lands in subsequent steps.
}

View File

@@ -0,0 +1,5 @@
import type { ApiRoutesContext } from "./types.js";
export function registerTaskRoutes(_ctx: ApiRoutesContext): void {
// Step scaffold: route extraction lands in subsequent steps.
}

View File

@@ -0,0 +1,56 @@
import type { Request, Response, Router } from "express";
import type { AutomationStore, RoutineStore, TaskStore } from "@fusion/core";
import type { ServerOptions } from "../server.js";
import type { RuntimeLogger } from "../runtime-logger.js";
export interface ProjectContext {
store: TaskStore;
engine: import("@fusion/engine").ProjectEngine | undefined;
projectId: string | undefined;
}
export interface RemoteRouteDiagnosticInput {
route: string;
message: string;
nodeId?: string;
upstreamPath?: string;
stage?: string;
operationStage?: string;
error?: unknown;
level?: "info" | "warn" | "error";
context?: Record<string, unknown>;
}
export interface AuthSyncAuditLogInput {
level?: "info" | "warn" | "error";
operation: "receive" | "sync";
direction: "push" | "pull" | "receive";
route: "/settings/auth-receive" | "/nodes/:id/auth/sync";
sourceNodeId?: string;
targetNodeId?: string;
providerNames: string[];
}
export type ScopeValue = "global" | "project";
export interface ApiRoutesContext {
router: Router;
store: TaskStore;
options?: ServerOptions;
runtimeLogger: RuntimeLogger;
planningLogger: RuntimeLogger;
proxyLogger: RuntimeLogger;
chatLogger: RuntimeLogger;
getProjectIdFromRequest(req: Request): string | undefined;
getScopedStore(req: Request): Promise<TaskStore>;
getProjectContext(req: Request): Promise<ProjectContext>;
prioritizeProjectsForCurrentDirectory<T extends { path: string }>(projects: T[]): T[];
emitRemoteRouteDiagnostic(input: RemoteRouteDiagnosticInput): void;
emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void;
proxyToRemoteNode(req: Request, res: Response, remotePath: string, options?: { timeoutMs?: number }): Promise<void>;
parseScopeParam(req: Request): ScopeValue | undefined;
resolveAutomationStore(req: Request, scope: ScopeValue | undefined): AutomationStore;
resolveRoutineStore(req: Request, scope: ScopeValue | undefined): RoutineStore;
resolveRoutineRunner(req: Request, scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]>;
rethrowAsApiError(error: unknown, fallbackMessage?: string): never;
}