From e1dddcbfae393e1908ab5db5363f63c3ef167098 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 00:46:14 -0700 Subject: [PATCH] FN-8404: extract dashboard domain route registrars Move maintenance, AI text assistant, and setup/activity endpoints into focused dashboard route registrars. - Register the extracted domains in the API mount sequence. - Preserve route precedence and document registrar responsibilities. - Add registrar coverage and refresh the modular-route baseline. Files changed: packages/dashboard/src/routes.ts | 948 +-------------------- packages/dashboard/src/routes/README.md | 82 +- .../register-ai-text-assistant-routes.test.ts | 49 ++ .../register-setup-activity-routes.test.ts | 54 ++ .../register-system-maintenance-routes.test.ts | 48 ++ .../src/routes/create-api-routes-mount-sequence.ts | 8 +- .../routes/register-ai-text-assistant-routes.ts | 327 +++++++ .../src/routes/register-setup-activity-routes.ts | 320 +++++++ .../routes/register-system-maintenance-routes.ts | 320 +++++++ scripts/lib/routes-modular-baseline.json | 2 +- 10 files changed, 1176 insertions(+), 982 deletions(-) Fusion-Task-Id: FN-8404 Fusion-Task-Lineage: d6b5be1f-e011-47fc-a7f8-5df89893766f Co-authored-by: Fusion (runfusion.ai) --- packages/dashboard/src/routes.ts | 948 +----------------- packages/dashboard/src/routes/README.md | 82 +- .../register-ai-text-assistant-routes.test.ts | 49 + .../register-setup-activity-routes.test.ts | 54 + ...register-system-maintenance-routes.test.ts | 48 + .../create-api-routes-mount-sequence.ts | 8 +- .../register-ai-text-assistant-routes.ts | 327 ++++++ .../routes/register-setup-activity-routes.ts | 320 ++++++ .../register-system-maintenance-routes.ts | 320 ++++++ scripts/lib/routes-modular-baseline.json | 2 +- 10 files changed, 1176 insertions(+), 982 deletions(-) create mode 100644 packages/dashboard/src/routes/__tests__/register-ai-text-assistant-routes.test.ts create mode 100644 packages/dashboard/src/routes/__tests__/register-setup-activity-routes.test.ts create mode 100644 packages/dashboard/src/routes/__tests__/register-system-maintenance-routes.test.ts create mode 100644 packages/dashboard/src/routes/register-ai-text-assistant-routes.ts create mode 100644 packages/dashboard/src/routes/register-setup-activity-routes.ts create mode 100644 packages/dashboard/src/routes/register-system-maintenance-routes.ts diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 9d0eac1d8e..1f9de5073f 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -9,10 +9,8 @@ declare module "express" { import multer from "multer"; import { resolve, sep, join, isAbsolute } from "node:path"; import * as nodeFs from "node:fs"; -import os from "node:os"; -import v8 from "node:v8"; -import type { AnthropicProviderRegistration, TaskStore, ActivityEventType, ModelPreset, McpServerDefinition, ThinkingLevel } from "@fusion/core"; +import type { AnthropicProviderRegistration, TaskStore, ModelPreset, McpServerDefinition, ThinkingLevel } from "@fusion/core"; import { type Task, type PiExtensionEntry, @@ -20,14 +18,10 @@ import { THINKING_LEVELS, MemoryBackendError, discoverPiExtensions, - findVitestProcessIds, - getAvailableMemoryBytes, getFusionAgentDir, getLegacyPiAgentDir, listAgentMemoryFiles, readAgentMemoryFile, - resolveTitleSummarizerSettingsModel, - resolveImportTranslateSettingsModel, writeAgentMemoryFile, validateMcpServerDefinitionDetailed, } from "@fusion/core"; @@ -42,15 +36,11 @@ import { ApiError, badRequest, notFound, - rateLimited, rethrowAsApiError, sendErrorResponse, unauthorized, } from "./api-error.js"; import { createPluginRouter } from "./plugin-routes.js"; -import { fetchFromRemoteNode } from "./routes/register-settings-sync-helpers.js"; - -import { createSessionDiagnostics } from "./ai-session-diagnostics.js"; import { createApiRoutesContext } from "./routes/context.js"; import { createRegistrarMounter } from "./routes/create-api-routes-mount-sequence.js"; import { registerTaskWorkflowRoutes } from "./routes/register-task-workflow-routes.js"; @@ -102,6 +92,9 @@ import { registerCliAgentSettingsRoutes } from "./routes/cli-agent-settings.js"; import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js"; import { registerApprovalRoutes } from "./routes/register-approval-routes.js"; import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js"; +import { registerSystemMaintenanceRoutes } from "./routes/register-system-maintenance-routes.js"; +import { registerAiTextAssistantRoutes } from "./routes/register-ai-text-assistant-routes.js"; +import { registerActivityLogRoutes, registerSetupActivityRoutes } from "./routes/register-setup-activity-routes.js"; import { runGitCommand } from "./routes/resolve-diff-base.js"; const TASK_DETAIL_ACTIVITY_LOG_LIMIT = 500; @@ -989,8 +982,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout registerDispose, dispose, } = createApiRoutesContext(store, options); - const summarizeDiagnostics = createSessionDiagnostics("ai-summarize"); - // Registrar mount order is part of the API contract. Keep specific routes // before generic parameter/wildcard routes to preserve Express precedence. // Proxy registrar must remain last so explicit /proxy handlers stay ahead @@ -1516,310 +1507,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout } }); - let lastCpuUsageSample: NodeJS.CpuUsage | null = null; - let lastCpuSampleAt: number | null = null; - - const getAppCpuPercent = (): number | null => { - const currentCpuUsage = process.cpuUsage(); - const currentSampleAt = Date.now(); - - if (lastCpuUsageSample === null || lastCpuSampleAt === null) { - lastCpuUsageSample = { user: currentCpuUsage.user, system: currentCpuUsage.system }; - lastCpuSampleAt = currentSampleAt; - return null; - } - - const elapsedMs = currentSampleAt - lastCpuSampleAt; - const cpuUsageDelta = process.cpuUsage(lastCpuUsageSample); - - lastCpuUsageSample = { user: currentCpuUsage.user, system: currentCpuUsage.system }; - lastCpuSampleAt = currentSampleAt; - - if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) { - return null; - } - - const elapsedMicros = elapsedMs * 1_000; - const usedMicros = cpuUsageDelta.user + cpuUsageDelta.system; - if (!Number.isFinite(usedMicros) || usedMicros < 0) { - return null; - } - - return Math.max(0, Number(((usedMicros / elapsedMicros) * 100).toFixed(1))); - }; - - const getVitestProcessIds = async (): Promise => { - // Async pgrep/ps via findVitestProcessIds so the dashboard's event loop - // stays responsive while the process table is walked. The helper filters - // matches to actual node processes — a bare `pgrep -f vitest` also matches - // wrapper shells, monitors, and editors whose command line merely mentions - // vitest, and SIGKILLing those took out unrelated process trees - // (2026-06-03 incident). - return findVitestProcessIds(); - }; - - const collectSystemStatsResponse = async (req: Request) => { - const mem = process.memoryUsage(); - const heapStats = v8.getHeapStatistics(); - const load = os.loadavg(); - const vitestProcessIds = await getVitestProcessIds(); - const cpuPercent = getAppCpuPercent(); - - let totalTasks = 0; - let activeTasks = 0; - const byColumn: Record = { - triage: 0, - todo: 0, - "in-progress": 0, - "in-review": 0, - done: 0, - archived: 0, - }; - const agentCounts = { idle: 0, active: 0, running: 0, error: 0 }; - let vitestLastAutoKillAt: string | null = null; - - try { - const { store: scopedStore } = await getProjectContext(req); - - const globalSettingsStore = scopedStore.getGlobalSettingsStore?.(); - if (globalSettingsStore?.getSettings) { - const globalSettings = await globalSettingsStore.getSettings(); - const candidate = (globalSettings as Record).vitestLastAutoKillAt; - if (typeof candidate === "string" && candidate.length > 0) { - vitestLastAutoKillAt = candidate; - } - } - - const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false }); - totalTasks = tasks.length; - - for (const task of tasks) { - byColumn[task.column] = (byColumn[task.column] ?? 0) + 1; - if (task.column === "in-progress" || task.column === "in-review") { - activeTasks += 1; - } - } - - const { AgentStore } = await import("@fusion/core"); - const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer: scopedStore.getAsyncLayer() ?? undefined }); - await agentStore.init(); - const agents = await agentStore.listAgents(); - for (const agent of agents) { - const state = agent.state as keyof typeof agentCounts; - if (state in agentCounts) { - agentCounts[state] += 1; - } - } - } catch { - // System stats should still be available even when project resolution/scoped store fails. - } - - return { - systemStats: { - rss: mem.rss, - heapUsed: mem.heapUsed, - heapTotal: mem.heapTotal, - heapLimit: heapStats.heap_size_limit, - external: mem.external, - arrayBuffers: mem.arrayBuffers, - cpuPercent, - loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0], - cpuCount: os.cpus().length, - systemTotalMem: os.totalmem(), - /* - FNXC:CommandCenter 2026-06-21-13:01: - The public `systemFreeMem` field carries OS-available memory so SystemStatsArea derives Memory Used from reclaimable-aware bytes and matches Activity Monitor on macOS. - */ - systemFreeMem: getAvailableMemoryBytes(), - pid: process.pid, - nodeVersion: process.version, - platform: `${process.platform}/${process.arch}`, - }, - taskStats: { - total: totalTasks, - byColumn, - active: activeTasks, - agents: agentCounts, - }, - vitestProcessCount: vitestProcessIds.length, - vitestLastAutoKillAt, - }; - }; - - /** - * GET /api/system-stats - * Returns process/system metrics plus task and agent aggregates. - */ - router.get("/system-stats", async (req, res) => { - try { - res.json(await collectSystemStatsResponse(req)); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /* - FNXC:CommandCenter 2026-06-21-00:00: - The Command Center System area can view per-node stats by defaulting to this local process and proxying remote selections to the node's own /api/system-stats endpoint through the existing authenticated remote-node helper. - Keep this route in routes.ts, rather than a domain registrar, because it intentionally reuses the local getAppCpuPercent/getVitestProcessIds closures and the shared local stats builder. - */ - router.get("/nodes/:id/system-stats", async (req, res) => { - try { - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - let node: Awaited>; - await central.init(); - try { - node = await central.getNode(req.params.id); - } finally { - await central.close(); - } - - if (!node) { - throw notFound("Node not found"); - } - - if (node.type === "local") { - res.json(await collectSystemStatsResponse(req)); - return; - } - - res.json(await fetchFromRemoteNode(node, "/api/system-stats")); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * POST /api/kill-vitest - * Kill all running vitest processes (excluding this process). - */ - router.post("/kill-vitest", async (_req, res) => { - try { - const vitestProcessIds = await getVitestProcessIds(); - const killedPids: number[] = []; - - for (const pid of vitestProcessIds) { - try { - process.kill(pid, "SIGKILL"); - killedPids.push(pid); - } catch { - // Process may have exited before kill. - } - } - - res.json({ - killed: killedPids.length, - pids: killedPids, - }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err, "Failed to kill vitest processes"); - } - }); - - // ── Maintenance Routes ───────────────────────────────────────────── - - /** - * GET /api/maintenance/legacy-automerge-stamps - * Dry-run the legacy auto-merge stamp cleanup and list candidates. - */ - router.get("/maintenance/legacy-automerge-stamps", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const candidates = await scopedStore.reconcileLegacyAutoMergeStamps(); - res.json({ candidates, count: candidates.length }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err, "Failed to list legacy auto-merge stamps"); - } - }); - - /** - * POST /api/maintenance/legacy-automerge-stamps/apply - * Apply the legacy auto-merge stamp cleanup via the store-owned reconcile API. - */ - router.post("/maintenance/legacy-automerge-stamps/apply", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const cleared = await scopedStore.reconcileLegacyAutoMergeStamps({ apply: true }); - res.json({ cleared, count: cleared.length }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err, "Failed to apply legacy auto-merge stamp cleanup"); - } - }); - - // ── Backup Routes ───────────────────────────────────────────────── - - /** - * GET /api/backups - * List all database backups with metadata. - */ - router.get("/backups", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const { createBackupManager, resolveGlobalBackupRoot } = await import("@fusion/core"); - const settings = await scopedStore.getSettings(); - const manager = createBackupManager(resolveGlobalBackupRoot(scopedStore), settings); - const backups = await manager.listBackups(); - - // Calculate total size - const totalSize = backups.reduce((sum, b) => sum + b.size, 0); - - res.json({ - backups, - count: backups.length, - totalSize, - }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err, "Failed to list backups"); - } - }); - - /** - * POST /api/backups - * Create a new database backup immediately. - */ - router.post("/backups", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const { runBackupCommand, resolveGlobalBackupRoot } = await import("@fusion/core"); - const settings = await scopedStore.getSettings(); - const result = await runBackupCommand(resolveGlobalBackupRoot(scopedStore), settings); - - if (result.success) { - res.json({ - success: true, - backupPath: result.backupPath, - output: result.output, - deletedCount: result.deletedCount, - }); - } else { - throw new ApiError(500, result.output); - } - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err, "Failed to create backup"); - } - }); + registrarMounter.mount("registerSystemMaintenanceRoutes", () => registerSystemMaintenanceRoutes(routeContext)); // Models registrarMounter.mount("registerModelRoutes", () => registerModelRoutes(routeContext)); @@ -1834,322 +1522,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // ---------- CLI binary install / status routes ---------- registrarMounter.mount("registerFnBinaryRoutes", () => registerFnBinaryRoutes(routeContext)); - /** - * POST /api/ai/refine-text - * AI-powered text refinement for task descriptions. - * Body: { text: string, type: string } - * Returns: { refined: string } - * - * Refinement types: clarify, add-details, expand, simplify - * Rate limited: 10 requests per hour per IP - */ - router.post("/ai/refine-text", async (req, res) => { - try { - const { text, type } = req.body; - const ip = req.ip || req.socket.remoteAddress || "unknown"; - - // Get scoped store and settings for prompt overrides - const { store: scopedStore } = await getProjectContext(req); - const rootDir = scopedStore.getRootDir(); - const settings = await scopedStore.getSettings(); - - const { - validateRefineRequest, - checkRateLimit, - getRateLimitResetTime, - refineText, - RateLimitError: _RateLimitError3, - ValidationError, - InvalidTypeError, - AiServiceError: _AiServiceError, - } = await import("./ai-refine.js"); - - // Check rate limit first - if (!checkRateLimit(ip)) { - const resetTime = getRateLimitResetTime(ip); - throw rateLimited(`Rate limit exceeded. Maximum 10 refinement requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`); - } - - // Validate request body - let validated; - try { - validated = validateRefineRequest(text, type); - } catch (err) { - if (err instanceof ValidationError) { - throw badRequest(err instanceof Error ? err.message : String(err)); - } - if (err instanceof InvalidTypeError) { - throw new ApiError(422, err instanceof Error ? err.message : String(err)); - } - throw err; - } - - // Process refinement with prompt overrides - const refined = await refineText( - validated.text, - validated.type, - rootDir, - settings.promptOverrides, - scopedStore, - ); - res.json({ refined }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - // Check error by name since error classes are from dynamic import - if (err instanceof Error && err.name === "RateLimitError") { - throw rateLimited(err.message); - } else if (err instanceof Error && err.name === "AiServiceError") { - rethrowAsApiError(err, "AI service error"); - } else { - rethrowAsApiError(err, "Failed to refine text"); - } - } - }); - - /** - * POST /api/ai/translate-text - * AI-powered translation for GitHub/GitLab import preview title+body. - * Body: { fields: { title?: string, body?: string }, targetLocale: string, sourceLocale?: string } - * Returns: { fields: { title?: string, body?: string } } - * - * Rate limited: shared AI-helper budget (10 requests per hour per IP with refine/draft) - * - * FNXC:GitHubImportTranslate 2026-07-14-12:00: - * Import Tasks offers on-demand translation when selected content is not the dashboard language. - */ - router.post("/ai/translate-text", async (req, res) => { - try { - const { fields, targetLocale, sourceLocale } = req.body ?? {}; - const ip = req.ip || req.socket.remoteAddress || "unknown"; - - const { store: scopedStore } = await getProjectContext(req); - const rootDir = scopedStore.getRootDir(); - const settings = await scopedStore.getSettings(); - - const { - validateTranslateRequest, - checkRateLimit, - getRateLimitResetTime, - translateText, - AiServiceError: _AiServiceErrorTranslate, - ValidationError, - } = await import("./ai-translate.js"); - - if (!checkRateLimit(ip)) { - const resetTime = getRateLimitResetTime(ip); - throw rateLimited( - `Rate limit exceeded. Maximum 10 AI helper requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`, - ); - } - - let validated; - try { - validated = validateTranslateRequest(fields, targetLocale, sourceLocale); - } catch (err) { - if (err instanceof ValidationError) { - throw badRequest(err instanceof Error ? err.message : String(err)); - } - throw err; - } - - /* - FNXC:GitHubImportTranslate 2026-07-15-09:30: - Manual (operator-clicked) translation resolves the same translate lane as auto-translation, so the model shown in Settings is the model that actually runs on both paths. - */ - const resolvedTranslateModel = resolveImportTranslateSettingsModel(settings); - const translated = await translateText( - validated, - rootDir, - settings.promptOverrides, - scopedStore, - resolvedTranslateModel.provider, - resolvedTranslateModel.modelId, - ); - res.json({ fields: translated }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if (err instanceof Error && err.name === "RateLimitError") { - throw rateLimited(err.message); - } else if (err instanceof Error && err.name === "AiServiceError") { - rethrowAsApiError(err, "AI service error"); - } else { - rethrowAsApiError(err, "Failed to translate text"); - } - } - }); - - /** - * POST /api/ai/draft-goal-description - * AI-powered goal description drafting from a goal title. - * Body: { title: string } - * Returns: { description: string } - * - * Rate limited: 10 requests per hour per IP - */ - router.post("/ai/draft-goal-description", async (req, res) => { - try { - const { title } = req.body; - const ip = req.ip || req.socket.remoteAddress || "unknown"; - - const { store: scopedStore } = await getProjectContext(req); - const rootDir = scopedStore.getRootDir(); - const settings = await scopedStore.getSettings(); - - const { - validateGoalDraftRequest, - checkRateLimit, - getRateLimitResetTime, - draftGoalDescription, - RateLimitError: _RateLimitError4, - ValidationError, - AiServiceError: _AiServiceError2, - } = await import("./ai-refine.js"); - - if (!checkRateLimit(ip)) { - const resetTime = getRateLimitResetTime(ip); - throw rateLimited(`Rate limit exceeded. Maximum 10 draft requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`); - } - - let validatedTitle: string; - try { - validatedTitle = validateGoalDraftRequest(title); - } catch (err) { - if (err instanceof ValidationError) { - throw badRequest(err instanceof Error ? err.message : String(err)); - } - throw err; - } - - const description = await draftGoalDescription(validatedTitle, rootDir, settings.promptOverrides, scopedStore); - res.json({ description }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if (err instanceof Error && err.name === "RateLimitError") { - throw rateLimited(err.message); - } else if (err instanceof Error && err.name === "AiServiceError") { - rethrowAsApiError(err, "AI service error"); - } else { - rethrowAsApiError(err, "Failed to draft goal description"); - } - } - }); - - /** - * POST /api/ai/summarize-title - * AI-powered title generation from task descriptions. - * Body: { description: string, provider?: string, modelId?: string } - * Returns: { title: string } - * - * Generates a concise title (≤60 characters) from descriptions longer than 200 characters. - * Long descriptions are accepted; core truncates model input before prompting. - * Rate limited: 10 requests per hour per IP - */ - router.post("/ai/summarize-title", async (req, res) => { - try { - const { description, provider, modelId } = req.body; - const ip = req.ip || req.socket.remoteAddress || "unknown"; - const { store: scopedStore } = await getProjectContext(req); - const rootDir = scopedStore.getRootDir(); - - const { - checkRateLimit, - getRateLimitResetTime, - summarizeTitle, - validateDescription, - MIN_DESCRIPTION_LENGTH, - RateLimitError: _RateLimitError4, - ValidationError: _ValidationError2, - AiServiceError: _AiServiceError2, - } = await import("@fusion/core"); - - // Optional debug tracing for summarize flows. - if (process.env.FUSION_DEBUG_AI) { - summarizeDiagnostics.info("Summarize title request", { - ip, - descriptionLength: typeof description === "string" ? description.length : 0, - operation: "summarize-title-request", - }); - } - - // Check rate limit first - if (!checkRateLimit(ip)) { - const resetTime = getRateLimitResetTime(ip); - throw rateLimited(`Rate limit exceeded. Maximum 10 summarization requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`); - } - - // Validate request body - try { - validateDescription(description); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if (err instanceof Error && err.name === "ValidationError") { - throw badRequest(err instanceof Error ? err.message : String(err)); - } - throw err; - } - - // Resolve model selection hierarchy for summarization: - // 1. Request body provider+modelId (request override) - // 2. Project title summarizer lane - // 3. Global title summarizer lane - // 4. Project planning lane - // 5. Project default override - // 6. Global default - // 7. Automatic model resolution (no explicit model) - const settings = await scopedStore.getSettings(); - const resolvedSummarySettings = resolveTitleSummarizerSettingsModel(settings); - - const resolvedProvider = - (provider && modelId ? provider : undefined) || - resolvedSummarySettings.provider; - - const resolvedModelId = - (provider && modelId ? modelId : undefined) || - resolvedSummarySettings.modelId; - - if (process.env.FUSION_DEBUG_AI) { - summarizeDiagnostics.info("Summarize title model resolved", { - provider: resolvedProvider ?? "auto", - modelId: resolvedModelId ?? "auto", - operation: "summarize-title-model-resolution", - }); - } - - // Process summarization - const title = await summarizeTitle(description, rootDir, resolvedProvider, resolvedModelId); - - if (!title) { - throw badRequest(`Description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`); - } - - res.json({ title }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - // Check error by name since error classes are from dynamic import - if (err instanceof Error && err.name === "RateLimitError") { - throw rateLimited(err.message); - } else if (err instanceof Error && err.name === "AiServiceError") { - throw new ApiError(503, err.message || "AI service temporarily unavailable"); - } else if (err instanceof Error && err.name === "ValidationError") { - throw badRequest(err instanceof Error ? err.message : String(err)); - } else { - summarizeDiagnostics.errorFromException("Unexpected summarize title error", err, { - operation: "summarize-title", - }); - rethrowAsApiError(err, "Failed to generate title"); - } - } - }); + registrarMounter.mount("registerAiTextAssistantRoutes", () => registerAiTextAssistantRoutes(routeContext)); registrarMounter.mount("registerUsageRoutes", () => registerUsageRoutes(routeContext)); /* @@ -2183,71 +1556,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // authed like the rest of /api (the approving principal is the token holder). registrarMounter.mount("registerCliAgentSettingsRoutes", () => registerCliAgentSettingsRoutes(routeContext)); - // ── Activity Log Routes ───────────────────────────────────────────── - - /** - * GET /api/activity - * Get activity log entries. - * Query params: limit (default 100, max 1000), since (ISO timestamp), type (event type filter) - * Returns: ActivityLogEntry[] sorted newest first - */ - router.get("/activity", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const limitParam = req.query.limit; - const sinceParam = req.query.since; - const typeParam = req.query.type; - - // Parse and validate limit. Omitted limit intentionally defaults to 100 - // to match the documented API contract and avoid unbounded history reads. - let limit = 100; - if (limitParam !== undefined) { - const parsed = Number.parseInt(limitParam as string, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - throw badRequest("limit must be a non-negative integer"); - } - limit = Math.min(parsed, 1000); // Max 1000 - } - - // Validate type if provided - const validTypes = ["task:created", "task:moved", "task:updated", "task:deleted", "task:merged", "task:failed", "settings:updated"]; - if (typeParam !== undefined && !validTypes.includes(typeParam as string)) { - throw badRequest(`Invalid type. Must be one of: ${validTypes.join(", ")}`); - } - - const options: { limit?: number; since?: string; type?: ActivityEventType } = { - limit, - since: sinceParam as string | undefined, - type: typeParam as ActivityEventType | undefined, - }; - - const entries = await scopedStore.getActivityLog(options); - res.json(entries); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * DELETE /api/activity - * Clear all activity log entries (maintenance endpoint). - * Returns: { success: true } - */ - router.delete("/activity", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - await scopedStore.clearActivityLog(); - res.json({ success: true }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); + registrarMounter.mount("registerActivityLogRoutes", () => registerActivityLogRoutes(routeContext)); // ── Workflow Step Templates (palette) ──────────────────────────────── @@ -2975,248 +2284,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout registrarMounter.mount("registerSettingsSyncInboundRoutes", () => registerSettingsSyncInboundRoutes(routeContext)); registrarMounter.mount("registerSecretsSyncInboundRoutes", () => registerSecretsSyncInboundRoutes(routeContext)); - /** - * GET /api/activity-feed - * Get unified activity feed across all projects. - * Query: limit, projectId, types - * Returns: ActivityFeedEntry[] - */ - router.get("/activity-feed", async (req, res) => { - try { - const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50; - const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined; - const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined; - const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined; - - const { CentralCore } = await import("@fusion/core"); - const central = new CentralCore(); - await central.init(); - - const entries = await central.getRecentActivity({ limit, projectId, types }); - await central.close(); - - res.json(entries); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * GET /api/global-concurrency - * Get global concurrency state across all projects. - * Returns: GlobalConcurrencyState - */ - router.get("/global-concurrency", async (_req, res) => { - try { - const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore(); - const shouldClose = !options?.centralCore; - if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init(); - - const state = await central.getGlobalConcurrencyState(); - const liveCounts = await central.getLiveRunningAgentCounts(); - - /* - FNXC:GlobalConcurrencyControls 2026-06-26-17:22: - The published global-concurrency route reads currentlyActive/projectsActive through CentralCore's live seam while preserving globalMaxConcurrent/queuedCount from slot bookkeeping. The dashboard-registered source only inspects already-open project stores, so this read stays side-effect-safe and never opens watchers or starts project runtimes. - */ - const liveState = { - ...state, - currentlyActive: liveCounts.currentlyActive, - projectsActive: liveCounts.projectsActive, - }; - - if (shouldClose) await central.close(); - - res.json(liveState); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * PUT /api/global-concurrency - * Update the system-wide concurrency limit across all projects. - * Body: { globalMaxConcurrent: number } - * Returns: GlobalConcurrencyState - */ - router.put("/global-concurrency", async (req, res) => { - const { globalMaxConcurrent } = req.body ?? {}; - if (!Number.isInteger(globalMaxConcurrent) || globalMaxConcurrent < 1 || globalMaxConcurrent > 10000) { - throw badRequest("globalMaxConcurrent must be an integer between 1 and 10000"); - } - - try { - const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore(); - const shouldClose = !options?.centralCore; - if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init(); - - const state = await central.updateGlobalConcurrency({ globalMaxConcurrent }); - if (shouldClose) await central.close(); - - res.json(state); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * GET /api/first-run-status - * Check if user has projects or needs setup wizard. - * Returns: { hasProjects: boolean, singleProjectPath: string | null } - */ - router.get("/first-run-status", async (_req, res) => { - try { - const { CentralCore, FirstRunDetector } = await import("@fusion/core"); - const central = options?.centralCore ?? new CentralCore(); - const shouldClose = !options?.centralCore; - const detector = new FirstRunDetector(central.getGlobalDir()); - - try { - if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { - await central.init(); - } - - const projects = await central.listProjects(); - const hasProjects = projects.length > 0; - const singleProjectPath = projects.length === 1 ? projects[0].path : null; - - res.json({ hasProjects, singleProjectPath }); - } catch (error) { - const detectedProjects = await detector.detectExistingProjects(process.cwd()); - const hasProjects = detectedProjects.length > 0; - const singleProjectPath = detectedProjects.length === 1 ? detectedProjects[0].path : null; - - console.warn( - `[routes:first-run-status] Falling back to detected projects after central DB error: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - - res.json({ hasProjects, singleProjectPath }); - } finally { - if (shouldClose) { - await central.close(); - } - } - - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * GET /api/setup-state - * Returns the first-run state and any detected projects for migration. - * This is used by the dashboard to determine what UI to show on startup. - */ - router.get("/setup-state", async (_req, res) => { - try { - const { CentralCore, FirstRunDetector } = await import("@fusion/core"); - const central = options?.centralCore ?? new CentralCore(); - const shouldClose = !options?.centralCore; - const detector = new FirstRunDetector(central.getGlobalDir()); - const detectedProjects = await detector.detectExistingProjects(process.cwd()); - let state: "fresh-install" | "setup-wizard" | "normal-operation" = detectedProjects.length > 0 - ? "setup-wizard" - : "fresh-install"; - let projects: Array<{ id: string; name: string; path: string }> = []; - let centralBackendAvailable = false; - - try { - if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { - await central.init(); - } - centralBackendAvailable = true; - state = await detector.detectFirstRunState(central); - projects = await central.listProjects(); - } catch (error) { - console.warn( - `[routes:setup-state] Unable to read central DB state: ${error instanceof Error ? error.message : String(error)}`, - ); - } finally { - if (shouldClose) { - await central.close(); - } - } - - res.json({ - state, - detectedProjects, - // FNXC:PostgresProjectDiscovery 2026-07-14-17:30: Report PostgreSQL - // central-registry availability, never legacy fusion-central.db presence. - hasCentralDb: centralBackendAvailable, - registeredProjects: projects.map((p) => ({ - id: p.id, - name: p.name, - path: p.path, - })), - }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * POST /api/complete-setup - * Complete the first-run setup by registering projects. - * Body: { projects: Array<{ path: string, name: string, isolationMode?: "in-process" | "child-process" }> } - */ - router.post("/complete-setup", async (req, res) => { - try { - const { CentralCore } = await import("@fusion/core"); - const { MigrationCoordinator } = await import("@fusion/core"); - - const { projects } = req.body as { - projects: Array<{ path: string; name: string; isolationMode?: "in-process" | "child-process" }>; - }; - - if (!Array.isArray(projects)) { - throw badRequest("projects must be an array"); - } - - const central = options?.centralCore ?? new CentralCore(); - const shouldClose = !options?.centralCore; - - if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { - await central.init(); - } - - try { - const coordinator = new MigrationCoordinator(central); - const result = await coordinator.completeSetup(projects); - - res.json({ - success: result.success, - projectsRegistered: result.projectsRegistered, - errors: result.errors, - }); - } finally { - if (shouldClose) { - await central.close(); - } - } - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); + registrarMounter.mount("registerSetupActivityRoutes", () => registerSetupActivityRoutes(routeContext)); // Dev server mount intentionally stays in this late position to keep route // precedence unchanged relative to existing wildcard handlers. diff --git a/packages/dashboard/src/routes/README.md b/packages/dashboard/src/routes/README.md index 3072471838..ccb325344a 100644 --- a/packages/dashboard/src/routes/README.md +++ b/packages/dashboard/src/routes/README.md @@ -25,11 +25,13 @@ The following is the complete top-level registrar map currently imported by `rou - `registerPluginsAutomationRoutes` — automation and routine CRUD/manual-run/webhook endpoints plus live SSE streams, and plugin-management endpoints. It preserves the `/plugins/:id` registry pass-through; `createPluginRouter` remains mounted later by `routes.ts` so `/plugins/registry` retains precedence. Its co-located `automation-live-run.ts`, `automation-step-execution.ts`, and `plugin-bundled-runtimes.ts` helpers own replayable output, execution, and bundled-runtime fallback metadata. - `registerApprovalRoutes` — domain registrar mounted by `createApiRoutes`. - `registerWorktrunkRoutes` — domain registrar mounted by `createApiRoutes`. +- `registerSystemMaintenanceRoutes` — early-mounted system stats, vitest, maintenance-stamp, and backup routes; distinct from the late `/system/*` Command Center panel registrar. - `registerModelRoutes` — domain registrar mounted by `createApiRoutes`. - `registerCustomProviderRoutes` — domain registrar mounted by `createApiRoutes`. - `registerAuthRoutes` — domain registrar mounted by `createApiRoutes`. - `registerRuntimeProviderRoutes` — domain registrar mounted by `createApiRoutes`. - `registerFnBinaryRoutes` — domain registrar mounted by `createApiRoutes`. +- `registerAiTextAssistantRoutes` — AI refine, translate, goal-draft, and title-summary endpoints. - `registerUsageRoutes` — domain registrar mounted by `createApiRoutes`. - `registerCommandCenterRoutes` — domain registrar mounted by `createApiRoutes`. - `registerKnowledgeRoutes` — domain registrar mounted by `createApiRoutes`. @@ -40,6 +42,7 @@ The following is the complete top-level registrar map currently imported by `rou - `registerDiagnosticsRoutes` — domain registrar mounted by `createApiRoutes`. - `registerCliAgentHooksRoute` — domain registrar mounted by `createApiRoutes`. - `registerCliAgentSettingsRoutes` — domain registrar mounted by `createApiRoutes`. +- `registerActivityLogRoutes` — the early activity-log GET/DELETE split export from `register-setup-activity-routes.ts`. - `registerAgentCoreListCreateRoutes` — domain registrar mounted by `createApiRoutes`. - `registerAgentImportExportRoutes` — domain registrar mounted by `createApiRoutes`. - `registerOrgPortabilityRoutes` — domain registrar mounted by `createApiRoutes`. @@ -59,6 +62,7 @@ The following is the complete top-level registrar map currently imported by `rou - `registerDiscoveryRoutes` — domain registrar mounted by `createApiRoutes`. - `registerSettingsSyncInboundRoutes` — domain registrar mounted by `createApiRoutes`. - `registerSecretsSyncInboundRoutes` — domain registrar mounted by `createApiRoutes`. +- `registerSetupActivityRoutes` — the late activity feed, concurrency, and setup split export from `register-setup-activity-routes.ts`. - `registerIntegratedDevServerRouter` — domain registrar mounted by `createApiRoutes`. - `registerAgentSkillsRoutes` — domain registrar mounted by `createApiRoutes`. - `registerProxyRoutes` — domain registrar mounted by `createApiRoutes`. @@ -85,43 +89,47 @@ Express matches in registration order. `create-api-routes-mount-sequence.ts` is 13. `registerPluginsAutomationRoutes` 14. `registerApprovalRoutes` 15. `registerWorktrunkRoutes` -16. `registerModelRoutes` -17. `registerCustomProviderRoutes` -18. `registerAuthRoutes` -19. `registerRuntimeProviderRoutes` -20. `registerFnBinaryRoutes` -21. `registerUsageRoutes` -22. `registerCommandCenterRoutes` -23. `registerKnowledgeRoutes` -24. `registerReportRoutes` -25. `registerSignalRoutes` -26. `registerMonitorRoutes` -27. `registerUpdateCheckRoutes` -28. `registerDiagnosticsRoutes` -29. `registerCliAgentHooksRoute` -30. `registerCliAgentSettingsRoutes` -31. `registerAgentCoreListCreateRoutes` -32. `registerAgentImportExportRoutes` -33. `registerOrgPortabilityRoutes` -34. `registerAgentCoreRoutes` -35. `registerAgentRuntimeRoutes` -36. `registerSystemRoutes` -37. `registerAgentReflectionRatingRoutes` -38. `registerAgentGenerationRoutes` -39. `registerIntegratedRouters` -40. `registerProjectRoutes` -41. `registerNodeRoutes` -42. `registerDockerNodeRoutes` -43. `registerDockerProvisioningRoutes` -44. `registerSettingsSyncRoutes` -45. `registerSecretsSyncRoutes` -46. `registerMeshRoutes` -47. `registerDiscoveryRoutes` -48. `registerSettingsSyncInboundRoutes` -49. `registerSecretsSyncInboundRoutes` -50. `registerIntegratedDevServerRouter` -51. `registerAgentSkillsRoutes` -52. `registerProxyRoutes` +16. `registerSystemMaintenanceRoutes` +17. `registerModelRoutes` +18. `registerCustomProviderRoutes` +19. `registerAuthRoutes` +20. `registerRuntimeProviderRoutes` +21. `registerFnBinaryRoutes` +22. `registerAiTextAssistantRoutes` +23. `registerUsageRoutes` +24. `registerCommandCenterRoutes` +25. `registerKnowledgeRoutes` +26. `registerReportRoutes` +27. `registerSignalRoutes` +28. `registerMonitorRoutes` +29. `registerUpdateCheckRoutes` +30. `registerDiagnosticsRoutes` +31. `registerCliAgentHooksRoute` +32. `registerCliAgentSettingsRoutes` +33. `registerActivityLogRoutes` +34. `registerAgentCoreListCreateRoutes` +35. `registerAgentImportExportRoutes` +36. `registerOrgPortabilityRoutes` +37. `registerAgentCoreRoutes` +38. `registerAgentRuntimeRoutes` +39. `registerSystemRoutes` +40. `registerAgentReflectionRatingRoutes` +41. `registerAgentGenerationRoutes` +42. `registerIntegratedRouters` +43. `registerProjectRoutes` +44. `registerNodeRoutes` +45. `registerDockerNodeRoutes` +46. `registerDockerProvisioningRoutes` +47. `registerSettingsSyncRoutes` +48. `registerSecretsSyncRoutes` +49. `registerMeshRoutes` +50. `registerDiscoveryRoutes` +51. `registerSettingsSyncInboundRoutes` +52. `registerSecretsSyncInboundRoutes` +53. `registerSetupActivityRoutes` +54. `registerIntegratedDevServerRouter` +55. `registerAgentSkillsRoutes` +56. `registerProxyRoutes` ## Ordering rules diff --git a/packages/dashboard/src/routes/__tests__/register-ai-text-assistant-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-ai-text-assistant-routes.test.ts new file mode 100644 index 0000000000..3fa2a17d38 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-ai-text-assistant-routes.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node +import express from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { request } from "../../test-request.js"; +import { registerAiTextAssistantRoutes } from "../register-ai-text-assistant-routes.js"; + +const refine = vi.hoisted(() => { class ValidationError extends Error {}; class InvalidTypeError extends Error {}; class RateLimitError extends Error {}; class AiServiceError extends Error {}; return { checkRateLimit: vi.fn(), validateRefineRequest: vi.fn(), validateGoalDraftRequest: vi.fn(), refineText: vi.fn(), draftGoalDescription: vi.fn(), getRateLimitResetTime: vi.fn(), ValidationError, InvalidTypeError, RateLimitError, AiServiceError }; }); +const translate = vi.hoisted(() => { class ValidationError extends Error {}; class AiServiceError extends Error {}; return { checkRateLimit: vi.fn(), validateTranslateRequest: vi.fn(), translateText: vi.fn(), getRateLimitResetTime: vi.fn(), ValidationError, AiServiceError }; }); +vi.mock("../../ai-refine.js", () => refine); +vi.mock("../../ai-translate.js", () => translate); +vi.mock("@fusion/core", async () => ({ ...(await vi.importActual("@fusion/core")), resolveImportTranslateSettingsModel: vi.fn(() => ({ provider: "p", modelId: "m" })), resolveTitleSummarizerSettingsModel: vi.fn() })); + +function app() { + const router = express.Router(); + registerAiTextAssistantRoutes({ router, getProjectContext: vi.fn().mockResolvedValue({ store: { getRootDir: () => "/root", getSettings: vi.fn().mockResolvedValue({ promptOverrides: {} }) } }) } as never); + const server = express(); server.use(express.json()); server.use("/api", router); + server.use((err: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => res.status(err.statusCode ?? 500).json({ error: err.message })); + return server; +} + +describe("registerAiTextAssistantRoutes", () => { + beforeEach(() => { vi.clearAllMocks(); refine.checkRateLimit.mockReturnValue(true); translate.checkRateLimit.mockReturnValue(true); }); + it("maps refine validation, invalid types, happy responses, and rate limits", async () => { + refine.validateRefineRequest.mockImplementationOnce(() => { throw new refine.ValidationError("bad text"); }).mockImplementationOnce(() => { throw new refine.InvalidTypeError("bad type"); }).mockReturnValueOnce({ text: "x", type: "clarify" }); + refine.refineText.mockResolvedValue("refined"); + const server = app(); + const invalid = await request(server, "POST", "/api/ai/refine-text", JSON.stringify({}), { "Content-Type": "application/json" }); expect(invalid.status).toBe(400); + expect((await request(server, "POST", "/api/ai/refine-text", JSON.stringify({}), { "Content-Type": "application/json" })).status).toBe(422); + expect((await request(server, "POST", "/api/ai/refine-text", JSON.stringify({ text: "x", type: "clarify" }), { "Content-Type": "application/json" })).body).toEqual({ refined: "refined" }); + refine.checkRateLimit.mockReturnValue(false); + expect((await request(server, "POST", "/api/ai/refine-text", JSON.stringify({}), { "Content-Type": "application/json" })).status).toBe(429); + }); + it("validates, translates with the resolved model, and rate limits", async () => { + translate.validateTranslateRequest.mockImplementationOnce(() => { throw new translate.ValidationError("bad fields"); }).mockReturnValueOnce({ fields: { title: "hi" } }); + translate.translateText.mockResolvedValue({ title: "bonjour" }); const server = app(); + expect((await request(server, "POST", "/api/ai/translate-text", JSON.stringify({}), { "Content-Type": "application/json" })).status).toBe(400); + expect((await request(server, "POST", "/api/ai/translate-text", JSON.stringify({ fields: {}, targetLocale: "fr" }), { "Content-Type": "application/json" })).body).toEqual({ fields: { title: "bonjour" } }); + expect(translate.translateText).toHaveBeenCalledWith(expect.anything(), "/root", {}, expect.anything(), "p", "m"); + translate.checkRateLimit.mockReturnValue(false); + expect((await request(server, "POST", "/api/ai/translate-text", JSON.stringify({}), { "Content-Type": "application/json" })).status).toBe(429); + }); + it("validates, drafts, and rate limits goal descriptions", async () => { + refine.validateGoalDraftRequest.mockImplementationOnce(() => { throw new refine.ValidationError("title required"); }).mockReturnValueOnce("Goal"); refine.draftGoalDescription.mockResolvedValue("draft"); const server = app(); + expect((await request(server, "POST", "/api/ai/draft-goal-description", JSON.stringify({}), { "Content-Type": "application/json" })).status).toBe(400); + expect((await request(server, "POST", "/api/ai/draft-goal-description", JSON.stringify({ title: "Goal" }), { "Content-Type": "application/json" })).body).toEqual({ description: "draft" }); + refine.checkRateLimit.mockReturnValue(false); + expect((await request(server, "POST", "/api/ai/draft-goal-description", JSON.stringify({}), { "Content-Type": "application/json" })).status).toBe(429); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/register-setup-activity-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-setup-activity-routes.test.ts new file mode 100644 index 0000000000..691ca290d1 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-setup-activity-routes.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import express from "express"; +import { describe, expect, it, vi } from "vitest"; +import { request } from "../../test-request.js"; +import { registerActivityLogRoutes, registerSetupActivityRoutes } from "../register-setup-activity-routes.js"; + +const core = vi.hoisted(() => ({ completeSetup: vi.fn(), central: {} as Record })); +const { completeSetup } = core; +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + CentralCore: class { constructor() { return core.central; } }, + MigrationCoordinator: class { completeSetup = completeSetup; }, + }; +}); + +function server(activityStore: Record = {}, central: Record = {}) { + core.central = { init: vi.fn(), close: vi.fn(), ...central }; + const router = express.Router(); + const context = { router, getProjectContext: vi.fn().mockResolvedValue({ store: activityStore }), options: { centralCore: central } }; + registerActivityLogRoutes(context as never); registerSetupActivityRoutes(context as never); + const app = express(); app.use(express.json()); app.use("/api", router); + app.use((err: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => res.status(err.statusCode ?? 500).json({ error: err.message })); + return app; +} + +describe("register setup/activity route contracts", () => { + it("validates and forwards activity queries and clears the log", async () => { + const getActivityLog = vi.fn().mockResolvedValue([{ id: "a" }]); const clearActivityLog = vi.fn(); const app = server({ getActivityLog, clearActivityLog }); + expect((await request(app, "GET", "/api/activity")).body).toEqual([{ id: "a" }]); expect(getActivityLog).toHaveBeenCalledWith({ limit: 100, since: undefined, type: undefined }); + expect((await request(app, "GET", "/api/activity?limit=-1")).status).toBe(400); expect((await request(app, "GET", "/api/activity?type=nope")).status).toBe(400); + await request(app, "GET", "/api/activity?limit=7&type=task:created"); expect(getActivityLog).toHaveBeenLastCalledWith({ limit: 7, since: undefined, type: "task:created" }); + expect((await request(app, "DELETE", "/api/activity")).body).toEqual({ success: true }); expect(clearActivityLog).toHaveBeenCalledOnce(); + }); + it("uses the supplied CentralCore for feed and live concurrency state", async () => { + const getRecentActivity = vi.fn().mockResolvedValue([{ id: "feed" }]); const getGlobalConcurrencyState = vi.fn().mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, projectsActive: 0, queuedCount: 2 }); const getLiveRunningAgentCounts = vi.fn().mockResolvedValue({ currentlyActive: 3, projectsActive: 2 }); + const app = server({}, { getRecentActivity, getGlobalConcurrencyState, getLiveRunningAgentCounts }); + expect((await request(app, "GET", "/api/activity-feed?limit=8&projectId=p&types=task:created,task:moved")).body).toEqual([{ id: "feed" }]); expect(getRecentActivity).toHaveBeenCalledWith({ limit: 8, projectId: "p", types: ["task:created", "task:moved"] }); + expect((await request(app, "GET", "/api/global-concurrency")).body).toEqual({ globalMaxConcurrent: 4, currentlyActive: 3, projectsActive: 2, queuedCount: 2 }); + }); + it("enforces concurrency bounds and updates valid state", async () => { + const updateGlobalConcurrency = vi.fn().mockResolvedValue({ globalMaxConcurrent: 9 }); const app = server({}, { updateGlobalConcurrency }); + for (const globalMaxConcurrent of [0, 10001, 1.5, "2"]) expect((await request(app, "PUT", "/api/global-concurrency", JSON.stringify({ globalMaxConcurrent }), { "Content-Type": "application/json" })).status).toBe(400); + expect((await request(app, "PUT", "/api/global-concurrency", JSON.stringify({ globalMaxConcurrent: 9 }), { "Content-Type": "application/json" })).body).toEqual({ globalMaxConcurrent: 9 }); expect(updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 9 }); + }); + it("rejects non-array setup projects and completes valid setup", async () => { + completeSetup.mockResolvedValue({ success: true, projectsRegistered: 1, errors: [] }); + const app = server({}, { isInitialized: () => true }); + expect((await request(app, "POST", "/api/complete-setup", JSON.stringify({ projects: {} }), { "Content-Type": "application/json" })).status).toBe(400); + expect((await request(app, "POST", "/api/complete-setup", JSON.stringify({ projects: [{ path: "/p", name: "P" }] }), { "Content-Type": "application/json" })).body).toEqual({ success: true, projectsRegistered: 1, errors: [] }); + expect(completeSetup).toHaveBeenCalledWith([{ path: "/p", name: "P" }]); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/register-system-maintenance-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-system-maintenance-routes.test.ts new file mode 100644 index 0000000000..a378114d20 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-system-maintenance-routes.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import express from "express"; +import { describe, expect, it, vi } from "vitest"; +import { request } from "../../test-request.js"; +import { registerSystemMaintenanceRoutes } from "../register-system-maintenance-routes.js"; + +const core = vi.hoisted(() => ({ createBackupManager: vi.fn(), resolveGlobalBackupRoot: vi.fn(() => "/backups"), runBackupCommand: vi.fn() })); +const { createBackupManager, resolveGlobalBackupRoot, runBackupCommand } = core; +vi.mock("@fusion/core", async () => ({ + ...(await vi.importActual("@fusion/core")), + findVitestProcessIds: vi.fn().mockResolvedValue([]), + createBackupManager: core.createBackupManager, + resolveGlobalBackupRoot: core.resolveGlobalBackupRoot, + runBackupCommand: core.runBackupCommand, +})); + +function app(store: Record) { + const router = express.Router(); + registerSystemMaintenanceRoutes({ router, getProjectContext: vi.fn().mockResolvedValue({ store }) } as never); + const server = express(); + server.use(express.json()); + server.use("/api", router); + server.use((err: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => res.status(err.statusCode ?? 500).json({ error: err.message })); + return server; +} + +describe("registerSystemMaintenanceRoutes", () => { + it("runs legacy auto-merge dry-run and apply contracts", async () => { + const reconcileLegacyAutoMergeStamps = vi.fn().mockResolvedValueOnce(["a"]).mockResolvedValueOnce(["a", "b"]); + const server = app({ reconcileLegacyAutoMergeStamps }); + expect((await request(server, "GET", "/api/maintenance/legacy-automerge-stamps")).body).toEqual({ candidates: ["a"], count: 1 }); + expect((await request(server, "POST", "/api/maintenance/legacy-automerge-stamps/apply")).body).toEqual({ cleared: ["a", "b"], count: 2 }); + expect(reconcileLegacyAutoMergeStamps).toHaveBeenNthCalledWith(1); + expect(reconcileLegacyAutoMergeStamps).toHaveBeenNthCalledWith(2, { apply: true }); + }); + + it("lists backups and maps failed backup command output to 500", async () => { + const listBackups = vi.fn().mockResolvedValue([{ size: 3 }, { size: 7 }]); + createBackupManager.mockReturnValue({ listBackups }); + runBackupCommand.mockResolvedValueOnce({ success: true, backupPath: "/backups/a", output: "ok", deletedCount: 1 }).mockResolvedValueOnce({ success: false, output: "disk full" }); + const server = app({ getSettings: vi.fn().mockResolvedValue({}) }); + expect((await request(server, "GET", "/api/backups")).body).toEqual({ backups: [{ size: 3 }, { size: 7 }], count: 2, totalSize: 10 }); + expect((await request(server, "POST", "/api/backups")).body).toEqual({ success: true, backupPath: "/backups/a", output: "ok", deletedCount: 1 }); + const failed = await request(server, "POST", "/api/backups"); + expect(failed.status).toBe(500); + expect(failed.body).toEqual({ error: "disk full" }); + }); +}); diff --git a/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts b/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts index c9cc447758..69032dbffe 100644 --- a/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts +++ b/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts @@ -9,16 +9,16 @@ export const CREATE_API_ROUTES_REGISTRAR_MOUNT_SEQUENCE = [ "registerSettingsMemoryRoutes", "registerSecretsRoutes", "registerTaskWorkflowRoutes", "registerWorkflowRoutes", "registerPlanningSubtaskRoutes", "registerChatRoutes", "registerChatRoomRoutes", "registerMessagingScriptRoutes", "registerGitGitHubRoutes", "registerGitLabRoutes", "registerFilesTerminalWorkspaceRoutes", "registerAgentsProjectsNodesRoutes", - "registerPluginsAutomationRoutes", "registerApprovalRoutes", "registerWorktrunkRoutes", "registerModelRoutes", + "registerPluginsAutomationRoutes", "registerApprovalRoutes", "registerWorktrunkRoutes", "registerSystemMaintenanceRoutes", "registerModelRoutes", "registerCustomProviderRoutes", "registerAuthRoutes", "registerRuntimeProviderRoutes", "registerFnBinaryRoutes", - "registerUsageRoutes", "registerCommandCenterRoutes", "registerKnowledgeRoutes", "registerReportRoutes", + "registerAiTextAssistantRoutes", "registerUsageRoutes", "registerCommandCenterRoutes", "registerKnowledgeRoutes", "registerReportRoutes", "registerSignalRoutes", "registerMonitorRoutes", "registerUpdateCheckRoutes", "registerDiagnosticsRoutes", - "registerCliAgentHooksRoute", "registerCliAgentSettingsRoutes", "registerAgentCoreListCreateRoutes", "registerAgentImportExportRoutes", + "registerCliAgentHooksRoute", "registerCliAgentSettingsRoutes", "registerActivityLogRoutes", "registerAgentCoreListCreateRoutes", "registerAgentImportExportRoutes", "registerOrgPortabilityRoutes", "registerAgentCoreRoutes", "registerAgentRuntimeRoutes", "registerSystemRoutes", "registerAgentReflectionRatingRoutes", "registerAgentGenerationRoutes", "registerIntegratedRouters", "registerProjectRoutes", "registerNodeRoutes", "registerDockerNodeRoutes", "registerDockerProvisioningRoutes", "registerSettingsSyncRoutes", "registerSecretsSyncRoutes", "registerMeshRoutes", "registerDiscoveryRoutes", "registerSettingsSyncInboundRoutes", - "registerSecretsSyncInboundRoutes", "registerIntegratedDevServerRouter", "registerAgentSkillsRoutes", "registerProxyRoutes", + "registerSecretsSyncInboundRoutes", "registerSetupActivityRoutes", "registerIntegratedDevServerRouter", "registerAgentSkillsRoutes", "registerProxyRoutes", ] as const; export type CreateApiRoutesRegistrarId = (typeof CREATE_API_ROUTES_REGISTRAR_MOUNT_SEQUENCE)[number]; diff --git a/packages/dashboard/src/routes/register-ai-text-assistant-routes.ts b/packages/dashboard/src/routes/register-ai-text-assistant-routes.ts new file mode 100644 index 0000000000..b51d264020 --- /dev/null +++ b/packages/dashboard/src/routes/register-ai-text-assistant-routes.ts @@ -0,0 +1,327 @@ +import { resolveImportTranslateSettingsModel, resolveTitleSummarizerSettingsModel } from "@fusion/core"; +import { createSessionDiagnostics } from "../ai-session-diagnostics.js"; +import { ApiError, badRequest, rateLimited, rethrowAsApiError } from "../api-error.js"; +import type { ApiRouteRegistrar } from "./types.js"; + +const summarizeDiagnostics = createSessionDiagnostics("ai-summarize"); + +export const registerAiTextAssistantRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getProjectContext } = ctx; +/** + * POST /api/ai/refine-text + * AI-powered text refinement for task descriptions. + * Body: { text: string, type: string } + * Returns: { refined: string } + * + * Refinement types: clarify, add-details, expand, simplify + * Rate limited: 10 requests per hour per IP + */ +router.post("/ai/refine-text", async (req, res) => { + try { + const { text, type } = req.body; + const ip = req.ip || req.socket.remoteAddress || "unknown"; + + // Get scoped store and settings for prompt overrides + const { store: scopedStore } = await getProjectContext(req); + const rootDir = scopedStore.getRootDir(); + const settings = await scopedStore.getSettings(); + + const { + validateRefineRequest, + checkRateLimit, + getRateLimitResetTime, + refineText, + RateLimitError: _RateLimitError3, + ValidationError, + InvalidTypeError, + AiServiceError: _AiServiceError, + } = await import("../ai-refine.js"); + + // Check rate limit first + if (!checkRateLimit(ip)) { + const resetTime = getRateLimitResetTime(ip); + throw rateLimited(`Rate limit exceeded. Maximum 10 refinement requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`); + } + + // Validate request body + let validated; + try { + validated = validateRefineRequest(text, type); + } catch (err) { + if (err instanceof ValidationError) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + if (err instanceof InvalidTypeError) { + throw new ApiError(422, err instanceof Error ? err.message : String(err)); + } + throw err; + } + + // Process refinement with prompt overrides + const refined = await refineText( + validated.text, + validated.type, + rootDir, + settings.promptOverrides, + scopedStore, + ); + res.json({ refined }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + // Check error by name since error classes are from dynamic import + if (err instanceof Error && err.name === "RateLimitError") { + throw rateLimited(err.message); + } else if (err instanceof Error && err.name === "AiServiceError") { + rethrowAsApiError(err, "AI service error"); + } else { + rethrowAsApiError(err, "Failed to refine text"); + } + } +}); + +/** + * POST /api/ai/translate-text + * AI-powered translation for GitHub/GitLab import preview title+body. + * Body: { fields: { title?: string, body?: string }, targetLocale: string, sourceLocale?: string } + * Returns: { fields: { title?: string, body?: string } } + * + * Rate limited: shared AI-helper budget (10 requests per hour per IP with refine/draft) + * + * FNXC:GitHubImportTranslate 2026-07-14-12:00: + * Import Tasks offers on-demand translation when selected content is not the dashboard language. + */ +router.post("/ai/translate-text", async (req, res) => { + try { + const { fields, targetLocale, sourceLocale } = req.body ?? {}; + const ip = req.ip || req.socket.remoteAddress || "unknown"; + + const { store: scopedStore } = await getProjectContext(req); + const rootDir = scopedStore.getRootDir(); + const settings = await scopedStore.getSettings(); + + const { + validateTranslateRequest, + checkRateLimit, + getRateLimitResetTime, + translateText, + AiServiceError: _AiServiceErrorTranslate, + ValidationError, + } = await import("../ai-translate.js"); + + if (!checkRateLimit(ip)) { + const resetTime = getRateLimitResetTime(ip); + throw rateLimited( + `Rate limit exceeded. Maximum 10 AI helper requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`, + ); + } + + let validated; + try { + validated = validateTranslateRequest(fields, targetLocale, sourceLocale); + } catch (err) { + if (err instanceof ValidationError) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + throw err; + } + + /* + FNXC:GitHubImportTranslate 2026-07-15-09:30: + Manual (operator-clicked) translation resolves the same translate lane as auto-translation, so the model shown in Settings is the model that actually runs on both paths. + */ + const resolvedTranslateModel = resolveImportTranslateSettingsModel(settings); + const translated = await translateText( + validated, + rootDir, + settings.promptOverrides, + scopedStore, + resolvedTranslateModel.provider, + resolvedTranslateModel.modelId, + ); + res.json({ fields: translated }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof Error && err.name === "RateLimitError") { + throw rateLimited(err.message); + } else if (err instanceof Error && err.name === "AiServiceError") { + rethrowAsApiError(err, "AI service error"); + } else { + rethrowAsApiError(err, "Failed to translate text"); + } + } +}); + +/** + * POST /api/ai/draft-goal-description + * AI-powered goal description drafting from a goal title. + * Body: { title: string } + * Returns: { description: string } + * + * Rate limited: 10 requests per hour per IP + */ +router.post("/ai/draft-goal-description", async (req, res) => { + try { + const { title } = req.body; + const ip = req.ip || req.socket.remoteAddress || "unknown"; + + const { store: scopedStore } = await getProjectContext(req); + const rootDir = scopedStore.getRootDir(); + const settings = await scopedStore.getSettings(); + + const { + validateGoalDraftRequest, + checkRateLimit, + getRateLimitResetTime, + draftGoalDescription, + RateLimitError: _RateLimitError4, + ValidationError, + AiServiceError: _AiServiceError2, + } = await import("../ai-refine.js"); + + if (!checkRateLimit(ip)) { + const resetTime = getRateLimitResetTime(ip); + throw rateLimited(`Rate limit exceeded. Maximum 10 draft requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`); + } + + let validatedTitle: string; + try { + validatedTitle = validateGoalDraftRequest(title); + } catch (err) { + if (err instanceof ValidationError) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + throw err; + } + + const description = await draftGoalDescription(validatedTitle, rootDir, settings.promptOverrides, scopedStore); + res.json({ description }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof Error && err.name === "RateLimitError") { + throw rateLimited(err.message); + } else if (err instanceof Error && err.name === "AiServiceError") { + rethrowAsApiError(err, "AI service error"); + } else { + rethrowAsApiError(err, "Failed to draft goal description"); + } + } +}); + +/** + * POST /api/ai/summarize-title + * AI-powered title generation from task descriptions. + * Body: { description: string, provider?: string, modelId?: string } + * Returns: { title: string } + * + * Generates a concise title (≤60 characters) from descriptions longer than 200 characters. + * Long descriptions are accepted; core truncates model input before prompting. + * Rate limited: 10 requests per hour per IP + */ +router.post("/ai/summarize-title", async (req, res) => { + try { + const { description, provider, modelId } = req.body; + const ip = req.ip || req.socket.remoteAddress || "unknown"; + const { store: scopedStore } = await getProjectContext(req); + const rootDir = scopedStore.getRootDir(); + + const { + checkRateLimit, + getRateLimitResetTime, + summarizeTitle, + validateDescription, + MIN_DESCRIPTION_LENGTH, + RateLimitError: _RateLimitError4, + ValidationError: _ValidationError2, + AiServiceError: _AiServiceError2, + } = await import("@fusion/core"); + + // Optional debug tracing for summarize flows. + if (process.env.FUSION_DEBUG_AI) { + summarizeDiagnostics.info("Summarize title request", { + ip, + descriptionLength: typeof description === "string" ? description.length : 0, + operation: "summarize-title-request", + }); + } + + // Check rate limit first + if (!checkRateLimit(ip)) { + const resetTime = getRateLimitResetTime(ip); + throw rateLimited(`Rate limit exceeded. Maximum 10 summarization requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`); + } + + // Validate request body + try { + validateDescription(description); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof Error && err.name === "ValidationError") { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + throw err; + } + + // Resolve model selection hierarchy for summarization: + // 1. Request body provider+modelId (request override) + // 2. Project title summarizer lane + // 3. Global title summarizer lane + // 4. Project planning lane + // 5. Project default override + // 6. Global default + // 7. Automatic model resolution (no explicit model) + const settings = await scopedStore.getSettings(); + const resolvedSummarySettings = resolveTitleSummarizerSettingsModel(settings); + + const resolvedProvider = + (provider && modelId ? provider : undefined) || + resolvedSummarySettings.provider; + + const resolvedModelId = + (provider && modelId ? modelId : undefined) || + resolvedSummarySettings.modelId; + + if (process.env.FUSION_DEBUG_AI) { + summarizeDiagnostics.info("Summarize title model resolved", { + provider: resolvedProvider ?? "auto", + modelId: resolvedModelId ?? "auto", + operation: "summarize-title-model-resolution", + }); + } + + // Process summarization + const title = await summarizeTitle(description, rootDir, resolvedProvider, resolvedModelId); + + if (!title) { + throw badRequest(`Description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`); + } + + res.json({ title }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + // Check error by name since error classes are from dynamic import + if (err instanceof Error && err.name === "RateLimitError") { + throw rateLimited(err.message); + } else if (err instanceof Error && err.name === "AiServiceError") { + throw new ApiError(503, err.message || "AI service temporarily unavailable"); + } else if (err instanceof Error && err.name === "ValidationError") { + throw badRequest(err instanceof Error ? err.message : String(err)); + } else { + summarizeDiagnostics.errorFromException("Unexpected summarize title error", err, { + operation: "summarize-title", + }); + rethrowAsApiError(err, "Failed to generate title"); + } + } +}); + +}; diff --git a/packages/dashboard/src/routes/register-setup-activity-routes.ts b/packages/dashboard/src/routes/register-setup-activity-routes.ts new file mode 100644 index 0000000000..e9c496aad0 --- /dev/null +++ b/packages/dashboard/src/routes/register-setup-activity-routes.ts @@ -0,0 +1,320 @@ +import type { ActivityEventType } from "@fusion/core"; +import { ApiError, badRequest, rethrowAsApiError } from "../api-error.js"; +import type { ApiRouteRegistrar } from "./types.js"; + +export const registerActivityLogRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getProjectContext } = ctx; +// ── Activity Log Routes ───────────────────────────────────────────── + +/** + * GET /api/activity + * Get activity log entries. + * Query params: limit (default 100, max 1000), since (ISO timestamp), type (event type filter) + * Returns: ActivityLogEntry[] sorted newest first + */ +router.get("/activity", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const limitParam = req.query.limit; + const sinceParam = req.query.since; + const typeParam = req.query.type; + + // Parse and validate limit. Omitted limit intentionally defaults to 100 + // to match the documented API contract and avoid unbounded history reads. + let limit = 100; + if (limitParam !== undefined) { + const parsed = Number.parseInt(limitParam as string, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw badRequest("limit must be a non-negative integer"); + } + limit = Math.min(parsed, 1000); // Max 1000 + } + + // Validate type if provided + const validTypes = ["task:created", "task:moved", "task:updated", "task:deleted", "task:merged", "task:failed", "settings:updated"]; + if (typeParam !== undefined && !validTypes.includes(typeParam as string)) { + throw badRequest(`Invalid type. Must be one of: ${validTypes.join(", ")}`); + } + + const options: { limit?: number; since?: string; type?: ActivityEventType } = { + limit, + since: sinceParam as string | undefined, + type: typeParam as ActivityEventType | undefined, + }; + + const entries = await scopedStore.getActivityLog(options); + res.json(entries); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +/** + * DELETE /api/activity + * Clear all activity log entries (maintenance endpoint). + * Returns: { success: true } + */ +router.delete("/activity", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + await scopedStore.clearActivityLog(); + res.json({ success: true }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +}; + +export const registerSetupActivityRoutes: ApiRouteRegistrar = (ctx) => { + const { router, options } = ctx; +/** + * GET /api/activity-feed + * Get unified activity feed across all projects. + * Query: limit, projectId, types + * Returns: ActivityFeedEntry[] + */ +router.get("/activity-feed", async (req, res) => { + try { + const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50; + const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined; + const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined; + const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined; + + const { CentralCore } = await import("@fusion/core"); + const central = new CentralCore(); + await central.init(); + + const entries = await central.getRecentActivity({ limit, projectId, types }); + await central.close(); + + res.json(entries); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +/** + * GET /api/global-concurrency + * Get global concurrency state across all projects. + * Returns: GlobalConcurrencyState + */ +router.get("/global-concurrency", async (_req, res) => { + try { + const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore(); + const shouldClose = !options?.centralCore; + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init(); + + const state = await central.getGlobalConcurrencyState(); + const liveCounts = await central.getLiveRunningAgentCounts(); + + /* + FNXC:GlobalConcurrencyControls 2026-06-26-17:22: + The published global-concurrency route reads currentlyActive/projectsActive through CentralCore's live seam while preserving globalMaxConcurrent/queuedCount from slot bookkeeping. The dashboard-registered source only inspects already-open project stores, so this read stays side-effect-safe and never opens watchers or starts project runtimes. + */ + const liveState = { + ...state, + currentlyActive: liveCounts.currentlyActive, + projectsActive: liveCounts.projectsActive, + }; + + if (shouldClose) await central.close(); + + res.json(liveState); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +/** + * PUT /api/global-concurrency + * Update the system-wide concurrency limit across all projects. + * Body: { globalMaxConcurrent: number } + * Returns: GlobalConcurrencyState + */ +router.put("/global-concurrency", async (req, res) => { + const { globalMaxConcurrent } = req.body ?? {}; + if (!Number.isInteger(globalMaxConcurrent) || globalMaxConcurrent < 1 || globalMaxConcurrent > 10000) { + throw badRequest("globalMaxConcurrent must be an integer between 1 and 10000"); + } + + try { + const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore(); + const shouldClose = !options?.centralCore; + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) await central.init(); + + const state = await central.updateGlobalConcurrency({ globalMaxConcurrent }); + if (shouldClose) await central.close(); + + res.json(state); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +/** + * GET /api/first-run-status + * Check if user has projects or needs setup wizard. + * Returns: { hasProjects: boolean, singleProjectPath: string | null } + */ +router.get("/first-run-status", async (_req, res) => { + try { + const { CentralCore, FirstRunDetector } = await import("@fusion/core"); + const central = options?.centralCore ?? new CentralCore(); + const shouldClose = !options?.centralCore; + const detector = new FirstRunDetector(central.getGlobalDir()); + + try { + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { + await central.init(); + } + + const projects = await central.listProjects(); + const hasProjects = projects.length > 0; + const singleProjectPath = projects.length === 1 ? projects[0].path : null; + + res.json({ hasProjects, singleProjectPath }); + } catch (error) { + const detectedProjects = await detector.detectExistingProjects(process.cwd()); + const hasProjects = detectedProjects.length > 0; + const singleProjectPath = detectedProjects.length === 1 ? detectedProjects[0].path : null; + + console.warn( + `[routes:first-run-status] Falling back to detected projects after central DB error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + res.json({ hasProjects, singleProjectPath }); + } finally { + if (shouldClose) { + await central.close(); + } + } + + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +/** + * GET /api/setup-state + * Returns the first-run state and any detected projects for migration. + * This is used by the dashboard to determine what UI to show on startup. + */ +router.get("/setup-state", async (_req, res) => { + try { + const { CentralCore, FirstRunDetector } = await import("@fusion/core"); + const central = options?.centralCore ?? new CentralCore(); + const shouldClose = !options?.centralCore; + const detector = new FirstRunDetector(central.getGlobalDir()); + const detectedProjects = await detector.detectExistingProjects(process.cwd()); + let state: "fresh-install" | "setup-wizard" | "normal-operation" = detectedProjects.length > 0 + ? "setup-wizard" + : "fresh-install"; + let projects: Array<{ id: string; name: string; path: string }> = []; + let centralBackendAvailable = false; + + try { + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { + await central.init(); + } + centralBackendAvailable = true; + state = await detector.detectFirstRunState(central); + projects = await central.listProjects(); + } catch (error) { + console.warn( + `[routes:setup-state] Unable to read central DB state: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + if (shouldClose) { + await central.close(); + } + } + + res.json({ + state, + detectedProjects, + // FNXC:PostgresProjectDiscovery 2026-07-14-17:30: Report PostgreSQL + // central-registry availability, never legacy fusion-central.db presence. + hasCentralDb: centralBackendAvailable, + registeredProjects: projects.map((p) => ({ + id: p.id, + name: p.name, + path: p.path, + })), + }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +/** + * POST /api/complete-setup + * Complete the first-run setup by registering projects. + * Body: { projects: Array<{ path: string, name: string, isolationMode?: "in-process" | "child-process" }> } + */ +router.post("/complete-setup", async (req, res) => { + try { + const { CentralCore } = await import("@fusion/core"); + const { MigrationCoordinator } = await import("@fusion/core"); + + const { projects } = req.body as { + projects: Array<{ path: string; name: string; isolationMode?: "in-process" | "child-process" }>; + }; + + if (!Array.isArray(projects)) { + throw badRequest("projects must be an array"); + } + + const central = options?.centralCore ?? new CentralCore(); + const shouldClose = !options?.centralCore; + + if (shouldClose || (typeof central.isInitialized === "function" && !central.isInitialized())) { + await central.init(); + } + + try { + const coordinator = new MigrationCoordinator(central); + const result = await coordinator.completeSetup(projects); + + res.json({ + success: result.success, + projectsRegistered: result.projectsRegistered, + errors: result.errors, + }); + } finally { + if (shouldClose) { + await central.close(); + } + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +}; diff --git a/packages/dashboard/src/routes/register-system-maintenance-routes.ts b/packages/dashboard/src/routes/register-system-maintenance-routes.ts new file mode 100644 index 0000000000..87be9a162a --- /dev/null +++ b/packages/dashboard/src/routes/register-system-maintenance-routes.ts @@ -0,0 +1,320 @@ +import type { Request } from "express"; +import { findVitestProcessIds, getAvailableMemoryBytes } from "@fusion/core"; +import { ApiError, notFound, rethrowAsApiError } from "../api-error.js"; +import { fetchFromRemoteNode } from "./register-settings-sync-helpers.js"; +import type { ApiRouteRegistrar } from "./types.js"; +import os from "node:os"; +import v8 from "node:v8"; + +/** + * FNXC:RouteModularity 2026-07-19-18:00: + * System maintenance routes moved with their process-local CPU sampling helpers so + * stats remain accurate while the registrar stays before model/auth routes. + */ +export const registerSystemMaintenanceRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getProjectContext } = ctx; +let lastCpuUsageSample: NodeJS.CpuUsage | null = null; +let lastCpuSampleAt: number | null = null; + +const getAppCpuPercent = (): number | null => { + const currentCpuUsage = process.cpuUsage(); + const currentSampleAt = Date.now(); + + if (lastCpuUsageSample === null || lastCpuSampleAt === null) { + lastCpuUsageSample = { user: currentCpuUsage.user, system: currentCpuUsage.system }; + lastCpuSampleAt = currentSampleAt; + return null; + } + + const elapsedMs = currentSampleAt - lastCpuSampleAt; + const cpuUsageDelta = process.cpuUsage(lastCpuUsageSample); + + lastCpuUsageSample = { user: currentCpuUsage.user, system: currentCpuUsage.system }; + lastCpuSampleAt = currentSampleAt; + + if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) { + return null; + } + + const elapsedMicros = elapsedMs * 1_000; + const usedMicros = cpuUsageDelta.user + cpuUsageDelta.system; + if (!Number.isFinite(usedMicros) || usedMicros < 0) { + return null; + } + + return Math.max(0, Number(((usedMicros / elapsedMicros) * 100).toFixed(1))); +}; + +const getVitestProcessIds = async (): Promise => { + // Async pgrep/ps via findVitestProcessIds so the dashboard's event loop + // stays responsive while the process table is walked. The helper filters + // matches to actual node processes — a bare `pgrep -f vitest` also matches + // wrapper shells, monitors, and editors whose command line merely mentions + // vitest, and SIGKILLing those took out unrelated process trees + // (2026-06-03 incident). + return findVitestProcessIds(); +}; + +const collectSystemStatsResponse = async (req: Request) => { + const mem = process.memoryUsage(); + const heapStats = v8.getHeapStatistics(); + const load = os.loadavg(); + const vitestProcessIds = await getVitestProcessIds(); + const cpuPercent = getAppCpuPercent(); + + let totalTasks = 0; + let activeTasks = 0; + const byColumn: Record = { + triage: 0, + todo: 0, + "in-progress": 0, + "in-review": 0, + done: 0, + archived: 0, + }; + const agentCounts = { idle: 0, active: 0, running: 0, error: 0 }; + let vitestLastAutoKillAt: string | null = null; + + try { + const { store: scopedStore } = await getProjectContext(req); + + const globalSettingsStore = scopedStore.getGlobalSettingsStore?.(); + if (globalSettingsStore?.getSettings) { + const globalSettings = await globalSettingsStore.getSettings(); + const candidate = (globalSettings as Record).vitestLastAutoKillAt; + if (typeof candidate === "string" && candidate.length > 0) { + vitestLastAutoKillAt = candidate; + } + } + + const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false }); + totalTasks = tasks.length; + + for (const task of tasks) { + byColumn[task.column] = (byColumn[task.column] ?? 0) + 1; + if (task.column === "in-progress" || task.column === "in-review") { + activeTasks += 1; + } + } + + const { AgentStore } = await import("@fusion/core"); + const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer: scopedStore.getAsyncLayer() ?? undefined }); + await agentStore.init(); + const agents = await agentStore.listAgents(); + for (const agent of agents) { + const state = agent.state as keyof typeof agentCounts; + if (state in agentCounts) { + agentCounts[state] += 1; + } + } + } catch { + // System stats should still be available even when project resolution/scoped store fails. + } + + return { + systemStats: { + rss: mem.rss, + heapUsed: mem.heapUsed, + heapTotal: mem.heapTotal, + heapLimit: heapStats.heap_size_limit, + external: mem.external, + arrayBuffers: mem.arrayBuffers, + cpuPercent, + loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0], + cpuCount: os.cpus().length, + systemTotalMem: os.totalmem(), + /* + FNXC:CommandCenter 2026-06-21-13:01: + The public `systemFreeMem` field carries OS-available memory so SystemStatsArea derives Memory Used from reclaimable-aware bytes and matches Activity Monitor on macOS. + */ + systemFreeMem: getAvailableMemoryBytes(), + pid: process.pid, + nodeVersion: process.version, + platform: `${process.platform}/${process.arch}`, + }, + taskStats: { + total: totalTasks, + byColumn, + active: activeTasks, + agents: agentCounts, + }, + vitestProcessCount: vitestProcessIds.length, + vitestLastAutoKillAt, + }; +}; + +/** + * GET /api/system-stats + * Returns process/system metrics plus task and agent aggregates. + */ +router.get("/system-stats", async (req, res) => { + try { + res.json(await collectSystemStatsResponse(req)); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +/* +FNXC:CommandCenter 2026-07-19-18:00: +The Command Center System area views per-node stats by defaulting to this local process and proxying remote selections to the node's authenticated /api/system-stats endpoint. The shared CPU/vitest helpers now live in this early registrar so the historical route precedence is retained without keeping the route inline in routes.ts. +*/ +router.get("/nodes/:id/system-stats", async (req, res) => { + try { + const { CentralCore } = await import("@fusion/core"); + const central = new CentralCore(); + let node: Awaited>; + await central.init(); + try { + node = await central.getNode(req.params.id); + } finally { + await central.close(); + } + + if (!node) { + throw notFound("Node not found"); + } + + if (node.type === "local") { + res.json(await collectSystemStatsResponse(req)); + return; + } + + res.json(await fetchFromRemoteNode(node, "/api/system-stats")); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } +}); + +/** + * POST /api/kill-vitest + * Kill all running vitest processes (excluding this process). + */ +router.post("/kill-vitest", async (_req, res) => { + try { + const vitestProcessIds = await getVitestProcessIds(); + const killedPids: number[] = []; + + for (const pid of vitestProcessIds) { + try { + process.kill(pid, "SIGKILL"); + killedPids.push(pid); + } catch { + // Process may have exited before kill. + } + } + + res.json({ + killed: killedPids.length, + pids: killedPids, + }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to kill vitest processes"); + } +}); + +// ── Maintenance Routes ───────────────────────────────────────────── + +/** + * GET /api/maintenance/legacy-automerge-stamps + * Dry-run the legacy auto-merge stamp cleanup and list candidates. + */ +router.get("/maintenance/legacy-automerge-stamps", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const candidates = await scopedStore.reconcileLegacyAutoMergeStamps(); + res.json({ candidates, count: candidates.length }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to list legacy auto-merge stamps"); + } +}); + +/** + * POST /api/maintenance/legacy-automerge-stamps/apply + * Apply the legacy auto-merge stamp cleanup via the store-owned reconcile API. + */ +router.post("/maintenance/legacy-automerge-stamps/apply", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const cleared = await scopedStore.reconcileLegacyAutoMergeStamps({ apply: true }); + res.json({ cleared, count: cleared.length }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to apply legacy auto-merge stamp cleanup"); + } +}); + +// ── Backup Routes ───────────────────────────────────────────────── + +/** + * GET /api/backups + * List all database backups with metadata. + */ +router.get("/backups", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const { createBackupManager, resolveGlobalBackupRoot } = await import("@fusion/core"); + const settings = await scopedStore.getSettings(); + const manager = createBackupManager(resolveGlobalBackupRoot(scopedStore), settings); + const backups = await manager.listBackups(); + + // Calculate total size + const totalSize = backups.reduce((sum, b) => sum + b.size, 0); + + res.json({ + backups, + count: backups.length, + totalSize, + }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to list backups"); + } +}); + +/** + * POST /api/backups + * Create a new database backup immediately. + */ +router.post("/backups", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const { runBackupCommand, resolveGlobalBackupRoot } = await import("@fusion/core"); + const settings = await scopedStore.getSettings(); + const result = await runBackupCommand(resolveGlobalBackupRoot(scopedStore), settings); + + if (result.success) { + res.json({ + success: true, + backupPath: result.backupPath, + output: result.output, + deletedCount: result.deletedCount, + }); + } else { + throw new ApiError(500, result.output); + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to create backup"); + } +}); + +}; diff --git a/scripts/lib/routes-modular-baseline.json b/scripts/lib/routes-modular-baseline.json index 38a4d21c1e..ac9c6ff3b3 100644 --- a/scripts/lib/routes-modular-baseline.json +++ b/scripts/lib/routes-modular-baseline.json @@ -1,3 +1,3 @@ { - "inlineRouteRegistrations": 42 + "inlineRouteRegistrations": 23 }