feat(FN-2538): merge fusion/fn-2538
This commit is contained in:
File diff suppressed because it is too large
Load Diff
49
packages/dashboard/src/routes/README.md
Normal file
49
packages/dashboard/src/routes/README.md
Normal 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.
|
||||
405
packages/dashboard/src/routes/context.ts
Normal file
405
packages/dashboard/src/routes/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
export function registerAgentsProjectsNodesRoutes(_ctx: ApiRoutesContext): void {
|
||||
// Step scaffold: route extraction lands in subsequent steps.
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
export function registerFilesTerminalWorkspaceRoutes(_ctx: ApiRoutesContext): void {
|
||||
// Step scaffold: route extraction lands in subsequent steps.
|
||||
}
|
||||
5
packages/dashboard/src/routes/register-git-github.ts
Normal file
5
packages/dashboard/src/routes/register-git-github.ts
Normal 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.
|
||||
}
|
||||
390
packages/dashboard/src/routes/register-messaging-scripts.ts
Normal file
390
packages/dashboard/src/routes/register-messaging-scripts.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
5
packages/dashboard/src/routes/register-planning-chat.ts
Normal file
5
packages/dashboard/src/routes/register-planning-chat.ts
Normal 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.
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
export function registerPluginsAutomationRoutes(_ctx: ApiRoutesContext): void {
|
||||
// Step scaffold: route extraction lands in subsequent steps.
|
||||
}
|
||||
392
packages/dashboard/src/routes/register-proxy.ts
Normal file
392
packages/dashboard/src/routes/register-proxy.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
export function registerSettingsMemoryRoutes(_ctx: ApiRoutesContext): void {
|
||||
// Step scaffold: route extraction lands in subsequent steps.
|
||||
}
|
||||
5
packages/dashboard/src/routes/register-tasks.ts
Normal file
5
packages/dashboard/src/routes/register-tasks.ts
Normal 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.
|
||||
}
|
||||
56
packages/dashboard/src/routes/types.ts
Normal file
56
packages/dashboard/src/routes/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user