feat(FN-1078): add central node registry support

- Add NodeConfig/NodeStatus types, export node types from @fusion/core, and include optional nodeId on registered projects
- Upgrade central DB schema to v2 with a nodes table, node indexes, and projects.nodeId migration handling
- Implement CentralCore node APIs for register/update/list/get/unregister, health checks, and project-to-node assignment flows with emitted events
- Seed a default local node during central init and mark it online using global concurrency settings
- Expand central-core and central-db tests to cover node defaults, schema changes, lifecycle operations, health transitions, and assignment behavior
This commit is contained in:
gsxdsm
2026-04-07 19:23:03 -07:00
parent 21f6d714da
commit b1c5fa03a3
6 changed files with 728 additions and 15 deletions

View File

@@ -42,6 +42,30 @@ describe("CentralCore", () => {
expect(central.isInitialized()).toBe(true);
});
it("should create a default online local node on init", async () => {
await central.init();
const nodes = await central.listNodes();
const localNodes = nodes.filter((node) => node.type === "local");
expect(localNodes).toHaveLength(1);
expect(localNodes[0].name).toBe("local");
expect(localNodes[0].status).toBe("online");
expect(localNodes[0].maxConcurrent).toBe(4);
});
it("should not create duplicate default local nodes across re-initialization", async () => {
await central.init();
await central.close();
central = new CentralCore(tempDir);
await central.init();
const nodes = await central.listNodes();
const localNodes = nodes.filter((node) => node.type === "local");
expect(localNodes).toHaveLength(1);
expect(localNodes[0].name).toBe("local");
});
it("should close and clean up", async () => {
await central.init();
await central.close();
@@ -534,6 +558,175 @@ describe("CentralCore", () => {
});
});
describe("node management", () => {
beforeEach(async () => {
await central.init();
});
it("should register and retrieve a node", async () => {
const node = await central.registerNode({
name: "executor-node-a",
type: "local",
maxConcurrent: 3,
});
expect(node.id).toMatch(/^node_[a-f0-9]+$/);
expect(node.name).toBe("executor-node-a");
expect(node.type).toBe("local");
expect(node.status).toBe("offline");
expect(node.maxConcurrent).toBe(3);
const fetched = await central.getNode(node.id);
expect(fetched).toEqual(node);
const byName = await central.getNodeByName("executor-node-a");
expect(byName?.id).toBe(node.id);
});
it("should reject duplicate node names", async () => {
await central.registerNode({ name: "dup-node", type: "local" });
await expect(
central.registerNode({ name: "dup-node", type: "local" }),
).rejects.toThrow("already exists");
});
it("should validate node type constraints on register", async () => {
await expect(
central.registerNode({ name: "remote-missing-url", type: "remote" }),
).rejects.toThrow("must include a url");
await expect(
central.registerNode({
name: "local-with-url",
type: "local",
url: "https://example.com",
}),
).rejects.toThrow("must not include url or apiKey");
await expect(
central.registerNode({
name: "local-with-key",
type: "local",
apiKey: "abc",
}),
).rejects.toThrow("must not include url or apiKey");
});
it("should update nodes and enforce type constraints", async () => {
const remote = await central.registerNode({
name: "remote-node",
type: "remote",
url: "https://node.example.com",
apiKey: "secret",
});
const updated = await central.updateNode(remote.id, {
status: "connecting",
maxConcurrent: 4,
});
expect(updated.status).toBe("connecting");
expect(updated.maxConcurrent).toBe(4);
await expect(
central.updateNode(remote.id, {
type: "local",
}),
).rejects.toThrow("must not include url or apiKey");
});
it("should list nodes ordered by name", async () => {
await central.registerNode({ name: "z-node", type: "local" });
await central.registerNode({ name: "a-node", type: "local" });
const nodes = await central.listNodes();
const names = nodes.map((node) => node.name);
expect(names).toContain("a-node");
expect(names).toContain("z-node");
expect(names.indexOf("a-node")).toBeLessThan(names.indexOf("z-node"));
});
it("should assign and unassign projects to nodes", async () => {
const projectPath = join(tempDir, "node-assignment");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Node Assignment",
path: projectPath,
});
const node = await central.registerNode({ name: "assign-node", type: "local" });
const assigned = await central.assignProjectToNode(project.id, node.id);
expect(assigned.nodeId).toBe(node.id);
expect((await central.getProject(project.id))?.nodeId).toBe(node.id);
const unassigned = await central.unassignProjectFromNode(project.id);
expect(unassigned.nodeId).toBeUndefined();
expect((await central.getProject(project.id))?.nodeId).toBeUndefined();
});
it("should throw when assigning to unknown project or node", async () => {
const node = await central.registerNode({ name: "assignment-target", type: "local" });
await expect(central.assignProjectToNode("proj_missing", node.id)).rejects.toThrow("Project not found");
const projectPath = join(tempDir, "node-assignment-errors");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Node Assignment Errors",
path: projectPath,
});
await expect(central.assignProjectToNode(project.id, "node_missing")).rejects.toThrow("Node not found");
await expect(central.unassignProjectFromNode("proj_missing")).rejects.toThrow("Project not found");
});
it("should unassign projects when a node is unregistered", async () => {
const projectPath = join(tempDir, "node-unregister");
mkdirSync(projectPath);
projectPaths.push(projectPath);
const project = await central.registerProject({
name: "Node Unregister",
path: projectPath,
});
const node = await central.registerNode({ name: "ephemeral-node", type: "local" });
await central.assignProjectToNode(project.id, node.id);
await central.unregisterNode(node.id);
expect(await central.getNode(node.id)).toBeUndefined();
expect((await central.getProject(project.id))?.nodeId).toBeUndefined();
});
it("should be idempotent when unregistering missing nodes", async () => {
await expect(central.unregisterNode("node_missing")).resolves.toBeUndefined();
});
it("should check local node health and emit node:health:changed", async () => {
const node = await central.registerNode({ name: "local-health", type: "local" });
let emittedNodeId: string | undefined;
let emittedStatus: string | undefined;
central.on("node:health:changed", (updated) => {
emittedNodeId = updated.id;
emittedStatus = updated.status;
});
const status = await central.checkNodeHealth(node.id);
expect(status).toBe("online");
const stored = await central.getNode(node.id);
expect(stored?.status).toBe("online");
expect(emittedNodeId).toBe(node.id);
expect(emittedStatus).toBe("online");
});
});
describe("project health", () => {
beforeEach(async () => {
await central.init();