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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 00:46:14 -07:00
parent e22c65ad11
commit e1dddcbfae
10 changed files with 1176 additions and 982 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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`
<!-- mount-sequence:end -->
## Ordering rules

View File

@@ -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<typeof import("@fusion/core")>("@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);
});
});

View File

@@ -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<string, unknown> }));
const { completeSetup } = core;
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
CentralCore: class { constructor() { return core.central; } },
MigrationCoordinator: class { completeSetup = completeSetup; },
};
});
function server(activityStore: Record<string, unknown> = {}, central: Record<string, unknown> = {}) {
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" }]);
});
});

View File

@@ -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<typeof import("@fusion/core")>("@fusion/core")),
findVitestProcessIds: vi.fn().mockResolvedValue([]),
createBackupManager: core.createBackupManager,
resolveGlobalBackupRoot: core.resolveGlobalBackupRoot,
runBackupCommand: core.runBackupCommand,
}));
function app(store: Record<string, unknown>) {
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" });
});
});

View File

@@ -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];

View File

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

View File

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

View File

@@ -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<number[]> => {
// 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<string, number> = {
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<string, unknown>).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<ReturnType<typeof central.getNode>>;
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");
}
});
};

View File

@@ -1,3 +1,3 @@
{
"inlineRouteRegistrations": 42
"inlineRouteRegistrations": 23
}