feat(FN-1080): add node management APIs and CLI commands
- Add dashboard /api/nodes CRUD, health-check, and metrics routes backed by CentralCore with request validation - Extend project updates and frontend API types to support node assignment via nodeId - Add dashboard API client helpers for node list/register/update/delete/health/metrics operations - Implement fn node list/add/remove/show/health commands with validation, confirmation, and table/JSON output - Add route and CLI/bin tests plus a minor @gsxdsm/fusion changeset for node management support
This commit is contained in:
5
.changeset/add-node-management-cli.md
Normal file
5
.changeset/add-node-management-cli.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add node management CLI commands (`fn node list/add/remove/show/health`) and wire dashboard-facing node management API clients/routes for multi-node orchestration.
|
||||||
@@ -17,6 +17,11 @@ const runProjectShow = vi.fn();
|
|||||||
const runProjectInfo = vi.fn();
|
const runProjectInfo = vi.fn();
|
||||||
const runProjectSetDefault = vi.fn();
|
const runProjectSetDefault = vi.fn();
|
||||||
const runProjectDetect = vi.fn();
|
const runProjectDetect = vi.fn();
|
||||||
|
const runNodeList = vi.fn();
|
||||||
|
const runNodeAdd = vi.fn();
|
||||||
|
const runNodeRemove = vi.fn();
|
||||||
|
const runNodeShow = vi.fn();
|
||||||
|
const runNodeHealth = vi.fn();
|
||||||
|
|
||||||
vi.mock("../commands/dashboard.js", () => ({
|
vi.mock("../commands/dashboard.js", () => ({
|
||||||
runDashboard: vi.fn(),
|
runDashboard: vi.fn(),
|
||||||
@@ -81,6 +86,14 @@ vi.mock("../commands/project.js", () => ({
|
|||||||
runProjectDetect,
|
runProjectDetect,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../commands/node.js", () => ({
|
||||||
|
runNodeList,
|
||||||
|
runNodeAdd,
|
||||||
|
runNodeRemove,
|
||||||
|
runNodeShow,
|
||||||
|
runNodeHealth,
|
||||||
|
}));
|
||||||
|
|
||||||
describe("bin", () => {
|
describe("bin", () => {
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
@@ -188,6 +201,36 @@ describe("bin", () => {
|
|||||||
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: project wat");
|
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: project wat");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes node subcommands and aliases", async () => {
|
||||||
|
await runBin(["node", "list"]);
|
||||||
|
await runBin(["node", "ls", "--json"]);
|
||||||
|
expect(runNodeList).toHaveBeenNthCalledWith(1, { json: false });
|
||||||
|
expect(runNodeList).toHaveBeenNthCalledWith(2, { json: true });
|
||||||
|
|
||||||
|
await runBin(["node", "add", "my-node", "--url", "https://node.example.com", "--api-key", "abc", "--max-concurrent", "3"]);
|
||||||
|
expect(runNodeAdd).toHaveBeenCalledWith("my-node", {
|
||||||
|
url: "https://node.example.com",
|
||||||
|
apiKey: "abc",
|
||||||
|
maxConcurrent: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runBin(["node", "remove", "my-node", "--force"]);
|
||||||
|
await runBin(["node", "rm", "my-node", "--force"]);
|
||||||
|
expect(runNodeRemove).toHaveBeenCalledWith("my-node", { force: true });
|
||||||
|
|
||||||
|
await runBin(["node", "show", "my-node"]);
|
||||||
|
await runBin(["node", "info", "my-node"]);
|
||||||
|
expect(runNodeShow).toHaveBeenCalledWith("my-node");
|
||||||
|
|
||||||
|
await runBin(["node", "health", "my-node"]);
|
||||||
|
expect(runNodeHealth).toHaveBeenCalledWith("my-node");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown node subcommands", async () => {
|
||||||
|
await expect(runBin(["node", "wat"])).rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: node wat");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects duplicate --project flags", async () => {
|
it("rejects duplicate --project flags", async () => {
|
||||||
await expect(runBin(["task", "list", "--project", "one", "-P", "two"]))
|
await expect(runBin(["task", "list", "--project", "one", "-P", "two"]))
|
||||||
.rejects.toThrow("Duplicate --project flag. Specify a project only once.");
|
.rejects.toThrow("Duplicate --project flag. Specify a project only once.");
|
||||||
@@ -204,6 +247,7 @@ describe("bin", () => {
|
|||||||
|
|
||||||
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
expect(help).toContain("fn project list | ls");
|
expect(help).toContain("fn project list | ls");
|
||||||
|
expect(help).toContain("fn node list | ls");
|
||||||
expect(runTaskList).not.toHaveBeenCalled();
|
expect(runTaskList).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,6 +266,7 @@ describe("bin", () => {
|
|||||||
|
|
||||||
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
expect(help).toContain("fn project list | ls");
|
expect(help).toContain("fn project list | ls");
|
||||||
|
expect(help).toContain("fn node list | ls");
|
||||||
expect(help).toContain("fn task comments <id>");
|
expect(help).toContain("fn task comments <id>");
|
||||||
expect(help).toContain("--project, -P <name>");
|
expect(help).toContain("--project, -P <name>");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./co
|
|||||||
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
|
||||||
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
|
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
|
||||||
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
|
||||||
|
const { runNodeList, runNodeAdd, runNodeRemove, runNodeShow, runNodeHealth } = await import("./commands/node.js");
|
||||||
const { runInit } = await import("./commands/init.js");
|
const { runInit } = await import("./commands/init.js");
|
||||||
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
|
const { runAgentStop, runAgentStart } = await import("./commands/agent.js");
|
||||||
const { runAgentImport } = await import("./commands/agent-import.js");
|
const { runAgentImport } = await import("./commands/agent-import.js");
|
||||||
@@ -100,6 +101,13 @@ Usage:
|
|||||||
fn project set-default | default <name>
|
fn project set-default | default <name>
|
||||||
Set default project
|
Set default project
|
||||||
fn project detect Detect project from current directory
|
fn project detect Detect project from current directory
|
||||||
|
fn node list | ls [--json] List all nodes
|
||||||
|
fn node add <name> [--url <url>] [--api-key <key>] [--max-concurrent <n>]
|
||||||
|
Register a new node
|
||||||
|
fn node remove | rm <name> [--force]
|
||||||
|
Unregister a node
|
||||||
|
fn node show | info [name] Show node details
|
||||||
|
fn node health <name> Check node health
|
||||||
fn settings Show current Fusion configuration
|
fn settings Show current Fusion configuration
|
||||||
fn settings set <key> <value> Update a configuration setting
|
fn settings set <key> <value> Update a configuration setting
|
||||||
fn settings export [opts] Export settings to a JSON file
|
fn settings export [opts] Export settings to a JSON file
|
||||||
@@ -170,6 +178,30 @@ function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; proj
|
|||||||
return { cleanedArgs, projectName };
|
return { cleanedArgs, projectName };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFlagValue(args: string[], flag: string): string | undefined {
|
||||||
|
const index = args.indexOf(flag);
|
||||||
|
if (index === -1 || index + 1 >= args.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (!value || value.startsWith("-")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlagValueNumber(args: string[], flag: string): number | undefined {
|
||||||
|
const value = getFlagValue(args, flag);
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if migration is needed and run it automatically.
|
* Check if migration is needed and run it automatically.
|
||||||
* This handles the transition from single-project to multi-project mode.
|
* This handles the transition from single-project to multi-project mode.
|
||||||
@@ -319,6 +351,46 @@ async function main() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "node": {
|
||||||
|
const subcommand = args[1];
|
||||||
|
switch (subcommand) {
|
||||||
|
case "list":
|
||||||
|
case "ls": {
|
||||||
|
await runNodeList({ json: args.includes("--json") });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "add": {
|
||||||
|
const name = args[2];
|
||||||
|
await runNodeAdd(name, {
|
||||||
|
url: getFlagValue(args, "--url"),
|
||||||
|
apiKey: getFlagValue(args, "--api-key"),
|
||||||
|
maxConcurrent: getFlagValueNumber(args, "--max-concurrent"),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove":
|
||||||
|
case "rm": {
|
||||||
|
const name = args[2];
|
||||||
|
await runNodeRemove(name, { force: args.includes("--force") });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "show":
|
||||||
|
case "info": {
|
||||||
|
await runNodeShow(args[2]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "health": {
|
||||||
|
await runNodeHealth(args[2]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error(`Unknown subcommand: node ${subcommand || ""}`);
|
||||||
|
console.log("Try: fn node list | add | remove | show | health");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "task": {
|
case "task": {
|
||||||
const subcommand = args[1];
|
const subcommand = args[1];
|
||||||
switch (subcommand) {
|
switch (subcommand) {
|
||||||
|
|||||||
240
packages/cli/src/commands/__tests__/node.test.ts
Normal file
240
packages/cli/src/commands/__tests__/node.test.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockListNodes = vi.fn();
|
||||||
|
const mockRegisterNode = vi.fn();
|
||||||
|
const mockGetNode = vi.fn();
|
||||||
|
const mockGetNodeByName = vi.fn();
|
||||||
|
const mockUnregisterNode = vi.fn();
|
||||||
|
const mockCheckNodeHealth = vi.fn();
|
||||||
|
const mockQuestion = vi.fn();
|
||||||
|
const mockRlClose = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", () => ({
|
||||||
|
CentralCore: vi.fn().mockImplementation(() => ({
|
||||||
|
init: mockInit,
|
||||||
|
close: mockClose,
|
||||||
|
listNodes: mockListNodes,
|
||||||
|
registerNode: mockRegisterNode,
|
||||||
|
getNode: mockGetNode,
|
||||||
|
getNodeByName: mockGetNodeByName,
|
||||||
|
unregisterNode: mockUnregisterNode,
|
||||||
|
checkNodeHealth: mockCheckNodeHealth,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:readline/promises", () => ({
|
||||||
|
createInterface: vi.fn().mockImplementation(() => ({
|
||||||
|
question: mockQuestion,
|
||||||
|
close: mockRlClose,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
runNodeList,
|
||||||
|
runNodeAdd,
|
||||||
|
runNodeRemove,
|
||||||
|
runNodeShow,
|
||||||
|
runNodeHealth,
|
||||||
|
} from "../node.js";
|
||||||
|
|
||||||
|
function makeNode(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "node_123",
|
||||||
|
name: "local-node",
|
||||||
|
type: "local",
|
||||||
|
status: "offline",
|
||||||
|
maxConcurrent: 2,
|
||||||
|
capabilities: ["executor"],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("node commands", () => {
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||||
|
throw new Error("process.exit");
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockListNodes.mockResolvedValue([]);
|
||||||
|
mockRegisterNode.mockResolvedValue(makeNode());
|
||||||
|
mockGetNode.mockResolvedValue(undefined);
|
||||||
|
mockGetNodeByName.mockResolvedValue(undefined);
|
||||||
|
mockUnregisterNode.mockResolvedValue(undefined);
|
||||||
|
mockCheckNodeHealth.mockResolvedValue("online");
|
||||||
|
mockQuestion.mockResolvedValue("y");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeList prints table output with nodes", async () => {
|
||||||
|
mockListNodes.mockResolvedValue([
|
||||||
|
makeNode({ name: "b-node" }),
|
||||||
|
makeNode({ name: "a-node" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await runNodeList();
|
||||||
|
|
||||||
|
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
|
expect(output).toContain("Registered Nodes");
|
||||||
|
expect(output).toContain("a-node");
|
||||||
|
expect(output).toContain("b-node");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeList supports JSON output", async () => {
|
||||||
|
const nodes = [makeNode({ name: "json-node" })];
|
||||||
|
mockListNodes.mockResolvedValue(nodes);
|
||||||
|
|
||||||
|
await runNodeList({ json: true });
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(JSON.stringify(nodes, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeList prints empty message when no nodes", async () => {
|
||||||
|
mockListNodes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await runNodeList();
|
||||||
|
|
||||||
|
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
|
expect(output).toContain("No nodes registered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeAdd registers local node", async () => {
|
||||||
|
mockRegisterNode.mockResolvedValue(makeNode({ id: "node_local", name: "local-node", type: "local" }));
|
||||||
|
|
||||||
|
await runNodeAdd("local-node", {});
|
||||||
|
|
||||||
|
expect(mockRegisterNode).toHaveBeenCalledWith({
|
||||||
|
name: "local-node",
|
||||||
|
type: "local",
|
||||||
|
url: undefined,
|
||||||
|
apiKey: undefined,
|
||||||
|
maxConcurrent: undefined,
|
||||||
|
});
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Registered node 'local-node'"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeAdd registers remote node with url and apiKey", async () => {
|
||||||
|
mockRegisterNode.mockResolvedValue(
|
||||||
|
makeNode({
|
||||||
|
id: "node_remote",
|
||||||
|
name: "remote-node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://node.example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNodeAdd("remote-node", {
|
||||||
|
url: "https://node.example.com",
|
||||||
|
apiKey: "secret",
|
||||||
|
maxConcurrent: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRegisterNode).toHaveBeenCalledWith({
|
||||||
|
name: "remote-node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://node.example.com",
|
||||||
|
apiKey: "secret",
|
||||||
|
maxConcurrent: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeAdd validates name format", async () => {
|
||||||
|
await expect(runNodeAdd("invalid name", {})).rejects.toThrow("process.exit");
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeAdd rejects missing name", async () => {
|
||||||
|
await expect(runNodeAdd(undefined as any, {})).rejects.toThrow("process.exit");
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeRemove removes with --force", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(makeNode({ id: "node_123", name: "to-remove" }));
|
||||||
|
|
||||||
|
await runNodeRemove("node_123", { force: true });
|
||||||
|
|
||||||
|
expect(mockUnregisterNode).toHaveBeenCalledWith("node_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeRemove prompts without --force", async () => {
|
||||||
|
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_222", name: "prompt-node" }));
|
||||||
|
mockQuestion.mockResolvedValue("y");
|
||||||
|
|
||||||
|
await runNodeRemove("prompt-node", { force: false });
|
||||||
|
|
||||||
|
expect(mockQuestion).toHaveBeenCalled();
|
||||||
|
expect(mockUnregisterNode).toHaveBeenCalledWith("node_222");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeRemove rejects unknown node", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(undefined);
|
||||||
|
mockGetNodeByName.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(runNodeRemove("missing", { force: true })).rejects.toThrow("process.exit");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeShow displays named node details", async () => {
|
||||||
|
mockGetNodeByName.mockResolvedValue(
|
||||||
|
makeNode({
|
||||||
|
id: "node_remote",
|
||||||
|
name: "remote-node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://node.example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runNodeShow("remote-node");
|
||||||
|
|
||||||
|
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
|
expect(output).toContain("Node: remote-node");
|
||||||
|
expect(output).toContain("URL: https://node.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeShow picks local node when no name provided", async () => {
|
||||||
|
mockListNodes.mockResolvedValue([
|
||||||
|
makeNode({ id: "node_remote", name: "remote", type: "remote", url: "https://remote" }),
|
||||||
|
makeNode({ id: "node_local", name: "local", type: "local" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await runNodeShow();
|
||||||
|
|
||||||
|
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
|
expect(output).toContain("Node: local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeShow rejects unknown node", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(undefined);
|
||||||
|
mockGetNodeByName.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(runNodeShow("missing")).rejects.toThrow("process.exit");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeHealth reports node health status", async () => {
|
||||||
|
mockGetNodeByName.mockResolvedValue(makeNode({ id: "node_1", name: "health-node" }));
|
||||||
|
mockCheckNodeHealth.mockResolvedValue("online");
|
||||||
|
|
||||||
|
await runNodeHealth("health-node");
|
||||||
|
|
||||||
|
expect(mockCheckNodeHealth).toHaveBeenCalledWith("node_1");
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(" Node 'health-node' health: online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runNodeHealth handles unknown node", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(undefined);
|
||||||
|
mockGetNodeByName.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(runNodeHealth("missing")).rejects.toThrow("process.exit");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Error: Node 'missing' not found.");
|
||||||
|
});
|
||||||
|
});
|
||||||
266
packages/cli/src/commands/node.ts
Normal file
266
packages/cli/src/commands/node.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { CentralCore, type NodeConfig } from "@fusion/core";
|
||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
|
||||||
|
/** Options for node list command. */
|
||||||
|
export interface NodeListOptions {
|
||||||
|
/** Output as JSON instead of table */
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for node add command. */
|
||||||
|
export interface NodeAddOptions {
|
||||||
|
/** Remote node URL (if provided, node is registered as remote) */
|
||||||
|
url?: string;
|
||||||
|
/** Optional API key for remote node authentication */
|
||||||
|
apiKey?: string;
|
||||||
|
/** Max concurrent tasks for the node */
|
||||||
|
maxConcurrent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for node remove command. */
|
||||||
|
export interface NodeRemoveOptions {
|
||||||
|
/** Skip confirmation prompt */
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all registered nodes.
|
||||||
|
*/
|
||||||
|
export async function runNodeList(options: NodeListOptions = {}): Promise<void> {
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nodes = await central.listNodes();
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify([], null, 2));
|
||||||
|
} else {
|
||||||
|
console.log("\n No nodes registered.");
|
||||||
|
console.log(" Register one with: kb node add <name>\n");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(sorted, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(" Registered Nodes:");
|
||||||
|
console.log();
|
||||||
|
console.log(" Name Type Status Max URL");
|
||||||
|
console.log(` ${"─".repeat(78)}`);
|
||||||
|
|
||||||
|
for (const node of sorted) {
|
||||||
|
const name = node.name.padEnd(16);
|
||||||
|
const type = node.type.padEnd(8);
|
||||||
|
const status = node.status.padEnd(12);
|
||||||
|
const max = String(node.maxConcurrent).padStart(3);
|
||||||
|
const url = node.type === "remote" ? (node.url ?? "-") : "-";
|
||||||
|
console.log(` ${name} ${type} ${status} ${max} ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(` ${sorted.length} node${sorted.length === 1 ? "" : "s"} registered`);
|
||||||
|
console.log();
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a node (local by default, remote when --url is provided).
|
||||||
|
*/
|
||||||
|
export async function runNodeAdd(name: string, options: NodeAddOptions = {}): Promise<void> {
|
||||||
|
if (!name) {
|
||||||
|
console.error("Usage: kb node add <name> [--url <url>] [--api-key <key>] [--max-concurrent <n>]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidNodeName(name)) {
|
||||||
|
console.error(`\n ✗ Invalid node name '${name}'`);
|
||||||
|
console.error(" Name must be 1-64 characters and contain only: a-z, A-Z, 0-9, _, -\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type: "local" | "remote" = options.url ? "remote" : "local";
|
||||||
|
const url = options.url?.trim();
|
||||||
|
|
||||||
|
if (type === "remote" && !url) {
|
||||||
|
console.error("\n ✗ --url is required when adding a remote node\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "local" && options.apiKey) {
|
||||||
|
console.error("\n ✗ --api-key is only valid for remote nodes\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.maxConcurrent !== undefined
|
||||||
|
&& (!Number.isFinite(options.maxConcurrent) || options.maxConcurrent < 1)
|
||||||
|
) {
|
||||||
|
console.error("\n ✗ --max-concurrent must be a number >= 1\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const node = await central.registerNode({
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
url,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
maxConcurrent: options.maxConcurrent,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(` ✓ Registered node '${node.name}'`);
|
||||||
|
console.log(` ID: ${node.id}`);
|
||||||
|
console.log(` Type: ${node.type}`);
|
||||||
|
console.log(` Max Concurrent: ${node.maxConcurrent}`);
|
||||||
|
if (node.type === "remote") {
|
||||||
|
console.log(` URL: ${node.url ?? "(missing)"}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a node.
|
||||||
|
*/
|
||||||
|
export async function runNodeRemove(name: string, options: NodeRemoveOptions = {}): Promise<void> {
|
||||||
|
if (!name) {
|
||||||
|
console.error("Usage: kb node remove <name> [--force]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const node = await findNodeByNameOrId(central, name);
|
||||||
|
if (!node) {
|
||||||
|
console.error(`Error: Node '${name}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.force) {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const answer = await rl.question(`Unregister node '${node.name}'? [y/N] `);
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
if (answer.trim().toLowerCase() !== "y") {
|
||||||
|
console.log("Cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await central.unregisterNode(node.id);
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(` ✓ Unregistered node '${node.name}'`);
|
||||||
|
if (node.type === "remote" && node.url) {
|
||||||
|
console.log(` URL: ${node.url}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show detailed node information.
|
||||||
|
*/
|
||||||
|
export async function runNodeShow(name?: string): Promise<void> {
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let node: NodeConfig | undefined;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
node = await findNodeByNameOrId(central, name);
|
||||||
|
} else {
|
||||||
|
const nodes = await central.listNodes();
|
||||||
|
node = nodes.find((candidate) => candidate.type === "local");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
console.error(`Error: Node '${name || "local"}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(` Node: ${node.name}`);
|
||||||
|
console.log(` ID: ${node.id}`);
|
||||||
|
console.log(` Type: ${node.type}`);
|
||||||
|
console.log(` Status: ${node.status}`);
|
||||||
|
if (node.type === "remote") {
|
||||||
|
console.log(` URL: ${node.url ?? "(missing)"}`);
|
||||||
|
}
|
||||||
|
console.log(` Max Concurrent: ${node.maxConcurrent}`);
|
||||||
|
console.log(` Capabilities: ${node.capabilities?.length ? node.capabilities.join(", ") : "(none)"}`);
|
||||||
|
console.log(` Created: ${node.createdAt}`);
|
||||||
|
console.log(` Updated: ${node.updatedAt}`);
|
||||||
|
console.log();
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a health check for a node.
|
||||||
|
*/
|
||||||
|
export async function runNodeHealth(name: string): Promise<void> {
|
||||||
|
if (!name) {
|
||||||
|
console.error("Usage: kb node health <name>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const node = await findNodeByNameOrId(central, name);
|
||||||
|
if (!node) {
|
||||||
|
console.error(`Error: Node '${name}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await central.checkNodeHealth(node.id);
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(` Node '${node.name}' health: ${status}`);
|
||||||
|
console.log();
|
||||||
|
} finally {
|
||||||
|
await central.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findNodeByNameOrId(
|
||||||
|
central: CentralCore,
|
||||||
|
nameOrId: string,
|
||||||
|
): Promise<NodeConfig | undefined> {
|
||||||
|
const byId = await central.getNode(nameOrId);
|
||||||
|
if (byId) {
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
return central.getNodeByName(nameOrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidNodeName(name: string): boolean {
|
||||||
|
if (!name || name.length < 1 || name.length > 64) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||||
|
}
|
||||||
@@ -2115,6 +2115,7 @@ export interface ProjectInfo {
|
|||||||
path: string;
|
path: string;
|
||||||
status: "active" | "paused" | "errored" | "initializing";
|
status: "active" | "paused" | "errored" | "initializing";
|
||||||
isolationMode: "in-process" | "child-process";
|
isolationMode: "in-process" | "child-process";
|
||||||
|
nodeId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastActivityAt?: string;
|
lastActivityAt?: string;
|
||||||
@@ -2190,6 +2191,28 @@ export interface ProjectCreateInput {
|
|||||||
isolationMode?: "in-process" | "child-process";
|
isolationMode?: "in-process" | "child-process";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Node information returned by node endpoints */
|
||||||
|
export interface NodeInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "local" | "remote";
|
||||||
|
url?: string;
|
||||||
|
status: "online" | "offline" | "connecting" | "error";
|
||||||
|
capabilities?: string[];
|
||||||
|
maxConcurrent: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input for creating a new node */
|
||||||
|
export interface NodeCreateInput {
|
||||||
|
name: string;
|
||||||
|
type: "local" | "remote";
|
||||||
|
url?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
maxConcurrent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Options for fetching activity feed */
|
/** Options for fetching activity feed */
|
||||||
export interface FeedOptions {
|
export interface FeedOptions {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -2253,6 +2276,51 @@ export function fetchProjects(): Promise<ProjectInfo[]> {
|
|||||||
return api<ProjectInfo[]>("/projects");
|
return api<ProjectInfo[]>("/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch all registered nodes */
|
||||||
|
export function fetchNodes(): Promise<NodeInfo[]> {
|
||||||
|
return api<NodeInfo[]>("/nodes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a new node */
|
||||||
|
export function registerNode(input: NodeCreateInput): Promise<NodeInfo> {
|
||||||
|
return api<NodeInfo>("/nodes", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a single node by ID */
|
||||||
|
export function fetchNode(id: string): Promise<NodeInfo> {
|
||||||
|
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing node */
|
||||||
|
export function updateNode(id: string, updates: Partial<NodeInfo>): Promise<NodeInfo> {
|
||||||
|
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregister a node */
|
||||||
|
export function unregisterNode(id: string): Promise<void> {
|
||||||
|
return api<void>(`/nodes/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger a node health check */
|
||||||
|
export function checkNodeHealth(id: string): Promise<{ status: string }> {
|
||||||
|
return api<{ status: string }>(`/nodes/${encodeURIComponent(id)}/health-check`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch runtime metrics for a node */
|
||||||
|
export function fetchNodeMetrics(id: string): Promise<Record<string, unknown>> {
|
||||||
|
return api<Record<string, unknown>>(`/nodes/${encodeURIComponent(id)}/metrics`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Browse directory entries for the directory picker */
|
/** Browse directory entries for the directory picker */
|
||||||
export interface BrowseDirectoryResult {
|
export interface BrowseDirectoryResult {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
|
|||||||
344
packages/dashboard/src/__tests__/node-routes.test.ts
Normal file
344
packages/dashboard/src/__tests__/node-routes.test.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
import { request } from "../test-request.js";
|
||||||
|
import { createServer } from "../server.js";
|
||||||
|
|
||||||
|
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockListNodes = vi.fn().mockResolvedValue([]);
|
||||||
|
const mockRegisterNode = vi.fn();
|
||||||
|
const mockGetNode = vi.fn();
|
||||||
|
const mockUpdateNode = vi.fn();
|
||||||
|
const mockUnregisterNode = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockCheckNodeHealth = vi.fn();
|
||||||
|
const mockUpdateProject = vi.fn();
|
||||||
|
const mockAssignProjectToNode = vi.fn();
|
||||||
|
const mockUnassignProjectFromNode = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
CentralCore: vi.fn().mockImplementation(() => ({
|
||||||
|
init: mockInit,
|
||||||
|
close: mockClose,
|
||||||
|
listNodes: mockListNodes,
|
||||||
|
registerNode: mockRegisterNode,
|
||||||
|
getNode: mockGetNode,
|
||||||
|
updateNode: mockUpdateNode,
|
||||||
|
unregisterNode: mockUnregisterNode,
|
||||||
|
checkNodeHealth: mockCheckNodeHealth,
|
||||||
|
updateProject: mockUpdateProject,
|
||||||
|
assignProjectToNode: mockAssignProjectToNode,
|
||||||
|
unassignProjectFromNode: mockUnassignProjectFromNode,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
class MockStore extends EventEmitter {
|
||||||
|
getRootDir(): string {
|
||||||
|
return "/tmp/fn-1080";
|
||||||
|
}
|
||||||
|
|
||||||
|
getDatabase() {
|
||||||
|
return {
|
||||||
|
exec: vi.fn(),
|
||||||
|
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getMissionStore() {
|
||||||
|
return {
|
||||||
|
listMissions: vi.fn().mockResolvedValue([]),
|
||||||
|
createMission: vi.fn(),
|
||||||
|
getMission: vi.fn(),
|
||||||
|
updateMission: vi.fn(),
|
||||||
|
deleteMission: vi.fn(),
|
||||||
|
listTemplates: vi.fn().mockResolvedValue([]),
|
||||||
|
createTemplate: vi.fn(),
|
||||||
|
getTemplate: vi.fn(),
|
||||||
|
updateTemplate: vi.fn(),
|
||||||
|
deleteTemplate: vi.fn(),
|
||||||
|
instantiateMission: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTasks(): Promise<Task[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: "node_local",
|
||||||
|
name: "local-node",
|
||||||
|
type: "local",
|
||||||
|
status: "online",
|
||||||
|
maxConcurrent: 2,
|
||||||
|
capabilities: ["executor"],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Node routes", () => {
|
||||||
|
const app = createServer(new MockStore() as any);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockListNodes.mockResolvedValue([]);
|
||||||
|
mockGetNode.mockResolvedValue(undefined);
|
||||||
|
mockRegisterNode.mockResolvedValue(makeNode());
|
||||||
|
mockUpdateNode.mockResolvedValue(makeNode({ name: "updated-node", maxConcurrent: 4 }));
|
||||||
|
mockCheckNodeHealth.mockResolvedValue("online");
|
||||||
|
mockUpdateProject.mockResolvedValue({
|
||||||
|
id: "proj_123",
|
||||||
|
name: "Project",
|
||||||
|
path: "/tmp/project",
|
||||||
|
status: "active",
|
||||||
|
isolationMode: "in-process",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
mockAssignProjectToNode.mockResolvedValue({
|
||||||
|
id: "proj_123",
|
||||||
|
name: "Project",
|
||||||
|
path: "/tmp/project",
|
||||||
|
status: "active",
|
||||||
|
isolationMode: "in-process",
|
||||||
|
nodeId: "node_local",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
mockUnassignProjectFromNode.mockResolvedValue({
|
||||||
|
id: "proj_123",
|
||||||
|
name: "Project",
|
||||||
|
path: "/tmp/project",
|
||||||
|
status: "active",
|
||||||
|
isolationMode: "in-process",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/nodes returns an empty array when no nodes are registered", async () => {
|
||||||
|
mockListNodes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await request(app, "GET", "/api/nodes");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/nodes returns node list", async () => {
|
||||||
|
mockListNodes.mockResolvedValue([
|
||||||
|
makeNode({ id: "node_b", name: "z-node" }),
|
||||||
|
makeNode({ id: "node_a", name: "a-node" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await request(app, "GET", "/api/nodes");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((res.body as any[])).toHaveLength(2);
|
||||||
|
expect((res.body as any[])[0].name).toBe("a-node");
|
||||||
|
expect((res.body as any[])[1].name).toBe("z-node");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/nodes registers a local node with minimal input", async () => {
|
||||||
|
mockRegisterNode.mockResolvedValue(makeNode({ id: "node_1", name: "node-one", type: "local" }));
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/nodes",
|
||||||
|
JSON.stringify({ name: "node-one", type: "local" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect((res.body as any).id).toBe("node_1");
|
||||||
|
expect(mockRegisterNode).toHaveBeenCalledWith(expect.objectContaining({ name: "node-one", type: "local" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/nodes registers a remote node with url", async () => {
|
||||||
|
mockRegisterNode.mockResolvedValue(
|
||||||
|
makeNode({ id: "node_remote", name: "remote-node", type: "remote", url: "https://node.example.com" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/nodes",
|
||||||
|
JSON.stringify({ name: "remote-node", type: "remote", url: "https://node.example.com" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect((res.body as any).type).toBe("remote");
|
||||||
|
expect(mockRegisterNode).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: "remote-node", type: "remote", url: "https://node.example.com" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/nodes returns 400 when name is missing", async () => {
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/nodes",
|
||||||
|
JSON.stringify({ type: "local" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/nodes returns 400 when remote node is missing url", async () => {
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/nodes",
|
||||||
|
JSON.stringify({ name: "remote-node", type: "remote" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/nodes returns 400 for invalid type", async () => {
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/nodes",
|
||||||
|
JSON.stringify({ name: "node", type: "invalid" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/nodes/:id returns node by id", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(makeNode({ id: "node_1", name: "node-one" }));
|
||||||
|
|
||||||
|
const res = await request(app, "GET", "/api/nodes/node_1");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((res.body as any).id).toBe("node_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/nodes/:id returns 404 for unknown id", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const res = await request(app, "GET", "/api/nodes/missing");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /api/nodes/:id updates node", async () => {
|
||||||
|
mockUpdateNode.mockResolvedValue(makeNode({ id: "node_1", name: "node-two", maxConcurrent: 6 }));
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/nodes/node_1",
|
||||||
|
JSON.stringify({ name: "node-two", maxConcurrent: 6 }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((res.body as any).name).toBe("node-two");
|
||||||
|
expect((res.body as any).maxConcurrent).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /api/nodes/:id returns 404 for unknown id", async () => {
|
||||||
|
mockUpdateNode.mockRejectedValue(new Error("Node not found: missing"));
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/nodes/missing",
|
||||||
|
JSON.stringify({ name: "new-name" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /api/nodes/:id unregisters node", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(makeNode({ id: "node_1" }));
|
||||||
|
|
||||||
|
const res = await request(app, "DELETE", "/api/nodes/node_1");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ success: true });
|
||||||
|
expect(mockUnregisterNode).toHaveBeenCalledWith("node_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /api/nodes/:id returns 404 for unknown id", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const res = await request(app, "DELETE", "/api/nodes/missing");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/nodes/:id/health-check returns health status", async () => {
|
||||||
|
mockCheckNodeHealth.mockResolvedValue("online");
|
||||||
|
|
||||||
|
const res = await request(app, "POST", "/api/nodes/node_1/health-check");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ status: "online" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/nodes/:id/health-check returns 404 for unknown id", async () => {
|
||||||
|
mockCheckNodeHealth.mockRejectedValue(new Error("Node not found: missing"));
|
||||||
|
|
||||||
|
const res = await request(app, "POST", "/api/nodes/missing/health-check");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/nodes/:id/metrics returns stub metrics for local node", async () => {
|
||||||
|
mockGetNode.mockResolvedValue(makeNode({ id: "node_1", type: "local", maxConcurrent: 8 }));
|
||||||
|
|
||||||
|
const res = await request(app, "GET", "/api/nodes/node_1/metrics");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
status: "online",
|
||||||
|
activeTasks: 0,
|
||||||
|
maxConcurrent: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /api/projects/:id assigns project to node when nodeId is provided", async () => {
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/projects/proj_123",
|
||||||
|
JSON.stringify({ nodeId: "node_local" }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockUpdateProject).toHaveBeenCalledWith("proj_123", {});
|
||||||
|
expect(mockAssignProjectToNode).toHaveBeenCalledWith("proj_123", "node_local");
|
||||||
|
expect((res.body as any).nodeId).toBe("node_local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /api/projects/:id unassigns project from node when nodeId is null", async () => {
|
||||||
|
const res = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/projects/proj_123",
|
||||||
|
JSON.stringify({ nodeId: null }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockUnassignProjectFromNode).toHaveBeenCalledWith("proj_123");
|
||||||
|
expect(res.body).not.toHaveProperty("nodeId");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7880,7 +7880,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
*/
|
*/
|
||||||
router.patch("/projects/:id", async (req, res) => {
|
router.patch("/projects/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, status, isolationMode } = req.body;
|
const { name, status, isolationMode, nodeId } = req.body;
|
||||||
|
|
||||||
const updates: Partial<import("@fusion/core").RegisteredProject> = {};
|
const updates: Partial<import("@fusion/core").RegisteredProject> = {};
|
||||||
if (name !== undefined) updates.name = name;
|
if (name !== undefined) updates.name = name;
|
||||||
@@ -7892,16 +7892,31 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
await central.init();
|
await central.init();
|
||||||
|
|
||||||
const project = await central.updateProject(req.params.id, updates);
|
const project = await central.updateProject(req.params.id, updates);
|
||||||
await central.close();
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
await central.close();
|
||||||
res.status(404).json({ error: "Project not found" });
|
res.status(404).json({ error: "Project not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resultProject = project;
|
||||||
|
if (nodeId !== undefined) {
|
||||||
|
if (nodeId === null) {
|
||||||
|
resultProject = await central.unassignProjectFromNode(req.params.id);
|
||||||
|
} else if (typeof nodeId === "string" && nodeId.trim()) {
|
||||||
|
resultProject = await central.assignProjectToNode(req.params.id, nodeId.trim());
|
||||||
|
} else {
|
||||||
|
await central.close();
|
||||||
|
res.status(400).json({ error: "nodeId must be a non-empty string or null" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await central.close();
|
||||||
|
|
||||||
res.json(project);
|
res.json(resultProject);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
const status = err.message?.includes("not found") ? 404 : 500;
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8053,6 +8068,223 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Node Management Routes (Multi-Node Support) ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/nodes
|
||||||
|
* List all registered nodes.
|
||||||
|
* Returns: NodeConfig[]
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
res.json(nodes);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/nodes
|
||||||
|
* Register a new node.
|
||||||
|
* Body: { name, type, url?, apiKey?, maxConcurrent?, capabilities? }
|
||||||
|
*/
|
||||||
|
router.post("/nodes", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, type, url, apiKey, maxConcurrent, capabilities } = req.body;
|
||||||
|
|
||||||
|
if (!name || typeof name !== "string" || !name.trim()) {
|
||||||
|
res.status(400).json({ error: "name is required and must be a non-empty string" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== "local" && type !== "remote") {
|
||||||
|
res.status(400).json({ error: "type must be 'local' or 'remote'" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "remote" && (!url || typeof url !== "string" || !url.trim())) {
|
||||||
|
res.status(400).json({ error: "url is required for remote nodes" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
maxConcurrent !== undefined
|
||||||
|
&& (typeof maxConcurrent !== "number" || !Number.isFinite(maxConcurrent) || maxConcurrent < 1)
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: "maxConcurrent must be a number >= 1" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
capabilities !== undefined
|
||||||
|
&& (!Array.isArray(capabilities) || capabilities.some((capability) => typeof capability !== "string"))
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: "capabilities must be an array of strings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
|
||||||
|
const node = await central.registerNode({
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
url: typeof url === "string" ? url.trim() : undefined,
|
||||||
|
apiKey: typeof apiKey === "string" ? apiKey : undefined,
|
||||||
|
maxConcurrent,
|
||||||
|
capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
await central.close();
|
||||||
|
res.status(201).json(node);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.message?.includes("already exists") ? 409 : err.message?.includes("must") ? 400 : 500;
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/nodes/:id
|
||||||
|
* Get node details by ID.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
res.status(404).json({ error: "Node not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(node);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/nodes/:id
|
||||||
|
* Update node config.
|
||||||
|
*/
|
||||||
|
router.patch("/nodes/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, url, apiKey, maxConcurrent, status, capabilities } = req.body;
|
||||||
|
|
||||||
|
const updates: Partial<Omit<import("@fusion/core").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 (capabilities !== undefined) updates.capabilities = capabilities;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
res.json(node);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.message?.includes("not found") ? 404 : err.message?.includes("must") ? 400 : 500;
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/nodes/:id
|
||||||
|
* Unregister a node.
|
||||||
|
*/
|
||||||
|
router.delete("/nodes/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
const central = new CentralCore();
|
||||||
|
await central.init();
|
||||||
|
|
||||||
|
const existing = await central.getNode(req.params.id);
|
||||||
|
if (!existing) {
|
||||||
|
await central.close();
|
||||||
|
res.status(404).json({ error: "Node not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await central.unregisterNode(req.params.id);
|
||||||
|
await central.close();
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/nodes/:id/health-check
|
||||||
|
* Trigger health check for a node.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
res.json({ status: healthStatus });
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.message?.includes("not found") ? 404 : 500;
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/nodes/:id/metrics
|
||||||
|
* Get node runtime metrics.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
res.status(404).json({ error: "Node not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "local") {
|
||||||
|
res.json({
|
||||||
|
status: "online",
|
||||||
|
activeTasks: 0,
|
||||||
|
maxConcurrent: node.maxConcurrent,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ error: "Remote node metrics not yet implemented" });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/activity-feed
|
* GET /api/activity-feed
|
||||||
* Get unified activity feed across all projects.
|
* Get unified activity feed across all projects.
|
||||||
|
|||||||
Reference in New Issue
Block a user