fix(dashboard): use active node registry authority (#2145)
## Summary - reuse the dashboard server's initialized `centralCore` for all node-management routes - preserve the legacy fallback only when no shared central authority is provided - never close the server-owned central authority from an individual request - cover node list and registration with regression tests that fail if a route constructs its own `CentralCore` ## Problem With the dashboard running on the PostgreSQL backend, `/api/nodes` and `POST /api/nodes` bypassed the server's initialized PostgreSQL-backed `centralCore` and constructed a separate legacy `CentralCore()`. Reads could hit the wrong registry, while writes failed with a null SQLite handle (`Cannot read properties of null (reading 'prepare')`). The same pattern affected node detail, health, path-mapping, version, plugin-sync, and Docker-config endpoints. ## Verification - targeted regression: 2 tests passed - dashboard typecheck passed - ESLint passed for changed TypeScript files - strict changeset validation passed - dashboard production build passed - `pnpm test:gate` passed against an isolated PostgreSQL 16 cluster: - engine core: 294 tests - PostgreSQL gate: 122 tests - CLI CI-shape: 63 tests ## Changeset Patch release for `@runfusion/fusion`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved multi-node management by keeping node operations connected to the active PostgreSQL registry. * Updated node listing, registration, configuration, health, version, plugin, path, and Docker configuration routes for more consistent behavior. * Preserved Docker configuration validation and safe response handling. * **Tests** * Added coverage for retrieving and registering nodes through the active registry. * Verified successful node creation responses and handling of optional configuration fields. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { request } from "../../test-request.js";
|
||||
import { registerNodeRoutes } from "../register-node-routes.js";
|
||||
import type { ApiRoutesContext } from "../types.js";
|
||||
|
||||
const { legacyCentralConstructor } = vi.hoisted(() => ({
|
||||
legacyCentralConstructor: vi.fn(function LegacyCentralCore() {
|
||||
throw new Error("node routes must use the injected central authority");
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: legacyCentralConstructor,
|
||||
};
|
||||
});
|
||||
|
||||
function buildApp(centralCore: Record<string, unknown> | undefined) {
|
||||
const router = express.Router();
|
||||
registerNodeRoutes({
|
||||
router,
|
||||
options: centralCore ? { centralCore: centralCore as never } : {},
|
||||
rethrowAsApiError(error: unknown): never {
|
||||
throw error;
|
||||
},
|
||||
} as unknown as ApiRoutesContext);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", router);
|
||||
app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(500).json({ error: error.message });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const centralCore = {
|
||||
init: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
listNodes: vi.fn(async () => [
|
||||
{ id: "node_z", name: "Zulu", type: "remote", status: "online" },
|
||||
{ id: "node_a", name: "Alpha", type: "local", status: "online" },
|
||||
]),
|
||||
registerNode: vi.fn(async (input: Record<string, unknown>) => ({
|
||||
id: "node_remote",
|
||||
status: "offline",
|
||||
...input,
|
||||
})),
|
||||
};
|
||||
|
||||
return { app: buildApp(centralCore), centralCore };
|
||||
}
|
||||
|
||||
describe("registerNodeRoutes PostgreSQL authority", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists nodes through the injected central authority", async () => {
|
||||
const { app, centralCore } = createFixture();
|
||||
|
||||
const response = await request(app, "GET", "/api/nodes");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([
|
||||
{ id: "node_a", name: "Alpha", type: "local", status: "online" },
|
||||
{ id: "node_z", name: "Zulu", type: "remote", status: "online" },
|
||||
]);
|
||||
expect(centralCore.listNodes).toHaveBeenCalledOnce();
|
||||
expect(centralCore.init).not.toHaveBeenCalled();
|
||||
expect(centralCore.close).not.toHaveBeenCalled();
|
||||
expect(legacyCentralConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers nodes through the injected central authority", async () => {
|
||||
const { app, centralCore } = createFixture();
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes",
|
||||
JSON.stringify({
|
||||
name: "macbook-air",
|
||||
type: "remote",
|
||||
url: "https://macbook-air.example.test:4041",
|
||||
maxConcurrent: 1,
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(centralCore.registerNode).toHaveBeenCalledWith({
|
||||
name: "macbook-air",
|
||||
type: "remote",
|
||||
url: "https://macbook-air.example.test:4041",
|
||||
apiKey: undefined,
|
||||
maxConcurrent: 1,
|
||||
capabilities: undefined,
|
||||
dockerConfig: undefined,
|
||||
});
|
||||
expect(centralCore.init).not.toHaveBeenCalled();
|
||||
expect(centralCore.close).not.toHaveBeenCalled();
|
||||
expect(legacyCentralConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// FNXC:NodeRegistry — a route-owned fallback authority (no injected centralCore) must close on
|
||||
// EVERY exit path, including when the underlying store operation throws mid-handler.
|
||||
it("closes a route-owned fallback authority when listNodes throws", async () => {
|
||||
const fallbackCentral = {
|
||||
init: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
listNodes: vi.fn(async () => {
|
||||
throw new Error("registry unavailable");
|
||||
}),
|
||||
};
|
||||
// Only the single fallback construction in this test uses the working fake; the default throwing
|
||||
// constructor is restored automatically afterward.
|
||||
legacyCentralConstructor.mockImplementationOnce(function ConstructedFallbackCentral() {
|
||||
return fallbackCentral;
|
||||
});
|
||||
|
||||
const app = buildApp(undefined);
|
||||
|
||||
const response = await request(app, "GET", "/api/nodes");
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(legacyCentralConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(fallbackCentral.init).toHaveBeenCalledOnce();
|
||||
expect(fallbackCentral.listNodes).toHaveBeenCalledOnce();
|
||||
expect(fallbackCentral.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CentralCore, sanitizeDockerNodeConfigForResponse, validateDockerNodeConfig } from "@fusion/core";
|
||||
import type { NodeConfig, NodeStatus } from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
@@ -44,7 +46,41 @@ function isDiscoveredRemoteProject(value: unknown): value is DiscoveredRemotePro
|
||||
}
|
||||
|
||||
export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, rethrowAsApiError } = ctx;
|
||||
const { router, options, rethrowAsApiError } = ctx;
|
||||
|
||||
/*
|
||||
FNXC:NodeRegistry 2026-07-15-00:00:
|
||||
Every node route needs a CentralCore authority to read/write the node registry.
|
||||
When the server injects a shared authority (`options.centralCore`), its lifecycle is owned by the
|
||||
server: the route must NEVER call `init()`/`close()` on it. When no shared authority is injected the
|
||||
route owns a fallback: construct once, `init()` once, and guarantee `close()` on EVERY exit path
|
||||
(success and error). A prior version closed the fallback only on success branches, so any handler that
|
||||
threw (validation, not-found, upstream failure) leaked the fallback connection. `withCentralCore`
|
||||
centralizes this so no handler can forget the finally-close, and closes best-effort if `init()` itself
|
||||
throws so a partially-opened fallback never leaks.
|
||||
*/
|
||||
const withCentralCore = async <T>(fn: (central: CentralCore) => T | Promise<T>): Promise<T> => {
|
||||
const shared = options?.centralCore;
|
||||
if (shared) {
|
||||
// Shared authority: server-owned lifecycle — do not init/close here.
|
||||
return await fn(shared);
|
||||
}
|
||||
|
||||
const central = new CentralCore();
|
||||
try {
|
||||
await central.init();
|
||||
} catch (initErr) {
|
||||
// init failed → close best-effort so a partially-opened fallback does not leak.
|
||||
await central.close().catch(() => {});
|
||||
throw initErr;
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn(central);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Node Management Routes (Multi-Node Support) ───────────────────────────
|
||||
|
||||
@@ -55,12 +91,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.get("/nodes", async (_req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const nodes = await central.listNodes();
|
||||
await central.close();
|
||||
const nodes = await withCentralCore((central) => central.listNodes());
|
||||
|
||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
res.json(nodes);
|
||||
@@ -106,11 +137,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
throw badRequest("capabilities must be an array of strings");
|
||||
}
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.registerNode({
|
||||
const node = await withCentralCore((central) => central.registerNode({
|
||||
name: name.trim(),
|
||||
type: nodeType,
|
||||
url: typeof url === "string" ? url.trim() : undefined,
|
||||
@@ -118,9 +145,8 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
maxConcurrent,
|
||||
capabilities,
|
||||
dockerConfig,
|
||||
});
|
||||
}));
|
||||
|
||||
await central.close();
|
||||
res.status(201).json(node);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -204,12 +230,10 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
* List all project path mappings for a node.
|
||||
*/
|
||||
router.get("/nodes/:id/path-mappings", async (req, res) => {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
|
||||
try {
|
||||
await central.init();
|
||||
const mappings = await central.listProjectNodePathMappingsForNode(req.params.id);
|
||||
const mappings = await withCentralCore((central) =>
|
||||
central.listProjectNodePathMappingsForNode(req.params.id),
|
||||
);
|
||||
res.json(mappings);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -218,8 +242,6 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const status = message.includes("Node not found") ? 404 : 500;
|
||||
throw new ApiError(status, message);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,12 +251,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.get("/nodes/:id", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.getNode(req.params.id);
|
||||
await central.close();
|
||||
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||
|
||||
if (!node) {
|
||||
throw notFound("Node not found");
|
||||
@@ -257,21 +274,16 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
try {
|
||||
const { name, url, apiKey, maxConcurrent, status, capabilities, dockerConfig } = req.body;
|
||||
|
||||
const updates: Partial<Omit<import("@fusion/core").NodeConfig, "id" | "createdAt">> = {};
|
||||
const updates: Partial<Omit<NodeConfig, "id" | "createdAt">> = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (url !== undefined) updates.url = url;
|
||||
if (apiKey !== undefined) updates.apiKey = apiKey;
|
||||
if (maxConcurrent !== undefined) updates.maxConcurrent = maxConcurrent;
|
||||
if (status !== undefined) updates.status = status as import("@fusion/core").NodeStatus;
|
||||
if (status !== undefined) updates.status = status as NodeStatus;
|
||||
if (capabilities !== undefined) updates.capabilities = capabilities;
|
||||
if (dockerConfig !== undefined) updates.dockerConfig = dockerConfig;
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.updateNode(req.params.id, updates);
|
||||
await central.close();
|
||||
const node = await withCentralCore((central) => central.updateNode(req.params.id, updates));
|
||||
|
||||
res.json(node);
|
||||
} catch (err: unknown) {
|
||||
@@ -293,11 +305,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.get("/nodes/:id/docker-config", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const node = await central.getNode(req.params.id);
|
||||
await central.close();
|
||||
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||
if (!node) throw notFound("Node not found");
|
||||
res.json(node.dockerConfig ? sanitizeDockerNodeConfigForResponse(node.dockerConfig) : null);
|
||||
} catch (err: unknown) {
|
||||
@@ -312,20 +320,17 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.put("/nodes/:id/docker-config", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core");
|
||||
const validation = validateDockerNodeConfig(req.body);
|
||||
if (!validation.valid || !validation.config) {
|
||||
throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] });
|
||||
}
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const node = await central.getNode(req.params.id);
|
||||
if (!node) {
|
||||
await central.close();
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
const updated = await central.updateNode(req.params.id, { dockerConfig: validation.config });
|
||||
await central.close();
|
||||
const updated = await withCentralCore(async (central) => {
|
||||
const node = await central.getNode(req.params.id);
|
||||
if (!node) {
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
return central.updateNode(req.params.id, { dockerConfig: validation.config });
|
||||
});
|
||||
res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
@@ -335,47 +340,42 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
router.patch("/nodes/:id/docker-config", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const node = await central.getNode(req.params.id);
|
||||
if (!node) {
|
||||
await central.close();
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
const existing = node.dockerConfig;
|
||||
if (!existing) {
|
||||
await central.close();
|
||||
throw badRequest("Node has no existing Docker config; use PUT first");
|
||||
}
|
||||
const updated = await withCentralCore(async (central) => {
|
||||
const node = await central.getNode(req.params.id);
|
||||
if (!node) {
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
const existing = node.dockerConfig;
|
||||
if (!existing) {
|
||||
throw badRequest("Node has no existing Docker config; use PUT first");
|
||||
}
|
||||
|
||||
const patch = req.body as Record<string, unknown>;
|
||||
const mergedEnvironment: Record<string, string> = { ...existing.environment };
|
||||
if (patch.environment && typeof patch.environment === "object" && !Array.isArray(patch.environment)) {
|
||||
for (const [key, value] of Object.entries(patch.environment as Record<string, unknown>)) {
|
||||
if (value === null) {
|
||||
delete mergedEnvironment[key];
|
||||
} else if (typeof value === "string") {
|
||||
mergedEnvironment[key] = value;
|
||||
const patch = req.body as Record<string, unknown>;
|
||||
const mergedEnvironment: Record<string, string> = { ...existing.environment };
|
||||
if (patch.environment && typeof patch.environment === "object" && !Array.isArray(patch.environment)) {
|
||||
for (const [key, value] of Object.entries(patch.environment as Record<string, unknown>)) {
|
||||
if (value === null) {
|
||||
delete mergedEnvironment[key];
|
||||
} else if (typeof value === "string") {
|
||||
mergedEnvironment[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...existing,
|
||||
...patch,
|
||||
environment: mergedEnvironment,
|
||||
volumeMounts: patch.volumeMounts !== undefined ? patch.volumeMounts : existing.volumeMounts,
|
||||
};
|
||||
const merged = {
|
||||
...existing,
|
||||
...patch,
|
||||
environment: mergedEnvironment,
|
||||
volumeMounts: patch.volumeMounts !== undefined ? patch.volumeMounts : existing.volumeMounts,
|
||||
};
|
||||
|
||||
const validation = validateDockerNodeConfig(merged);
|
||||
if (!validation.valid || !validation.config) {
|
||||
await central.close();
|
||||
throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] });
|
||||
}
|
||||
const validation = validateDockerNodeConfig(merged);
|
||||
if (!validation.valid || !validation.config) {
|
||||
throw new ApiError(400, "Invalid Docker config", { errors: validation.errors ?? [] });
|
||||
}
|
||||
|
||||
const updated = await central.updateNode(req.params.id, { dockerConfig: validation.config });
|
||||
await central.close();
|
||||
return central.updateNode(req.params.id, { dockerConfig: validation.config });
|
||||
});
|
||||
res.json(sanitizeDockerNodeConfigForResponse(updated.dockerConfig!));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
@@ -385,11 +385,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
router.get("/nodes/:id/docker-config/diff", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const node = await central.getNode(req.params.id);
|
||||
await central.close();
|
||||
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||
if (!node) throw notFound("Node not found");
|
||||
if (!node.dockerConfig) {
|
||||
res.json({ config: null });
|
||||
@@ -412,18 +408,14 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.delete("/nodes/:id", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
await withCentralCore(async (central) => {
|
||||
const existing = await central.getNode(req.params.id);
|
||||
if (!existing) {
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
|
||||
const existing = await central.getNode(req.params.id);
|
||||
if (!existing) {
|
||||
await central.close();
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
|
||||
await central.unregisterNode(req.params.id);
|
||||
await central.close();
|
||||
await central.unregisterNode(req.params.id);
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
} catch (err: unknown) {
|
||||
@@ -440,12 +432,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.post("/nodes/:id/health-check", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const healthStatus = await central.checkNodeHealth(req.params.id);
|
||||
await central.close();
|
||||
const healthStatus = await withCentralCore((central) => central.checkNodeHealth(req.params.id));
|
||||
|
||||
res.json({ status: healthStatus });
|
||||
} catch (err: unknown) {
|
||||
@@ -463,12 +450,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.get("/nodes/:id/metrics", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.getNode(req.params.id);
|
||||
await central.close();
|
||||
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||
|
||||
if (!node) {
|
||||
throw notFound("Node not found");
|
||||
@@ -491,12 +473,7 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.get("/nodes/:id/version", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const node = await central.getNode(req.params.id);
|
||||
await central.close();
|
||||
const node = await withCentralCore((central) => central.getNode(req.params.id));
|
||||
|
||||
if (!node) {
|
||||
throw notFound("Node not found");
|
||||
@@ -519,34 +496,28 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.post("/nodes/:id/sync-plugins", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const result = await withCentralCore(async (central) => {
|
||||
// Validate target node exists
|
||||
const targetNode = await central.getNode(req.params.id);
|
||||
if (!targetNode) {
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
|
||||
// Validate target node exists
|
||||
const targetNode = await central.getNode(req.params.id);
|
||||
if (!targetNode) {
|
||||
await central.close();
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
// Reject local target nodes - sync-plugins is for remote nodes only
|
||||
if (targetNode.type === "local") {
|
||||
throw badRequest("Cannot sync plugins to a local node - sync-plugins is for remote nodes only");
|
||||
}
|
||||
|
||||
// Reject local target nodes - sync-plugins is for remote nodes only
|
||||
if (targetNode.type === "local") {
|
||||
await central.close();
|
||||
throw badRequest("Cannot sync plugins to a local node - sync-plugins is for remote nodes only");
|
||||
}
|
||||
// Find the local node
|
||||
const nodes = await central.listNodes();
|
||||
const localNode = nodes.find((n) => n.type === "local");
|
||||
if (!localNode) {
|
||||
throw badRequest("Local node not registered - cannot perform sync");
|
||||
}
|
||||
|
||||
// Find the local node
|
||||
const nodes = await central.listNodes();
|
||||
const localNode = nodes.find((n) => n.type === "local");
|
||||
if (!localNode) {
|
||||
await central.close();
|
||||
throw badRequest("Local node not registered - cannot perform sync");
|
||||
}
|
||||
|
||||
// Perform plugin sync comparison
|
||||
const result = await central.syncPlugins(localNode.id, targetNode.id);
|
||||
await central.close();
|
||||
// Perform plugin sync comparison
|
||||
return central.syncPlugins(localNode.id, targetNode.id);
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
@@ -564,45 +535,38 @@ export const registerNodeRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.get("/nodes/:id/compatibility", async (req, res) => {
|
||||
try {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const result = await withCentralCore(async (central) => {
|
||||
// Validate target node exists
|
||||
const targetNode = await central.getNode(req.params.id);
|
||||
if (!targetNode) {
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
|
||||
// Validate target node exists
|
||||
const targetNode = await central.getNode(req.params.id);
|
||||
if (!targetNode) {
|
||||
await central.close();
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
// Find the local node
|
||||
const nodes = await central.listNodes();
|
||||
const localNode = nodes.find((n) => n.type === "local");
|
||||
if (!localNode) {
|
||||
throw badRequest("Local node not registered - cannot check compatibility");
|
||||
}
|
||||
|
||||
// Find the local node
|
||||
const nodes = await central.listNodes();
|
||||
const localNode = nodes.find((n) => n.type === "local");
|
||||
if (!localNode) {
|
||||
await central.close();
|
||||
throw badRequest("Local node not registered - cannot check compatibility");
|
||||
}
|
||||
// Get version info for both nodes
|
||||
const localVersionInfo = await central.getNodeVersionInfo(localNode.id);
|
||||
const targetVersionInfo = await central.getNodeVersionInfo(targetNode.id);
|
||||
|
||||
// Get version info for both nodes
|
||||
const localVersionInfo = await central.getNodeVersionInfo(localNode.id);
|
||||
const targetVersionInfo = await central.getNodeVersionInfo(targetNode.id);
|
||||
// Validate both have version info
|
||||
if (!localVersionInfo) {
|
||||
throw badRequest("Local node has no version info yet");
|
||||
}
|
||||
if (!targetVersionInfo) {
|
||||
throw badRequest("Target node has no version info yet");
|
||||
}
|
||||
|
||||
// Validate both have version info
|
||||
if (!localVersionInfo) {
|
||||
await central.close();
|
||||
throw badRequest("Local node has no version info yet");
|
||||
}
|
||||
if (!targetVersionInfo) {
|
||||
await central.close();
|
||||
throw badRequest("Target node has no version info yet");
|
||||
}
|
||||
|
||||
// Check compatibility using version strings
|
||||
const result = central.checkVersionCompatibility(
|
||||
localVersionInfo.appVersion,
|
||||
targetVersionInfo.appVersion,
|
||||
);
|
||||
await central.close();
|
||||
// Check compatibility using version strings
|
||||
return central.checkVersionCompatibility(
|
||||
localVersionInfo.appVersion,
|
||||
targetVersionInfo.appVersion,
|
||||
);
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user