From 75aa5519499eadc9a9e85897cca97db8421ba1c9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 8 Apr 2026 06:53:36 -0700 Subject: [PATCH] feat(FN-1223): add remote node connection APIs to CentralCore - Add a new NodeConnection utility that validates inputs, tests /api/health endpoints, normalizes URLs, and classifies connection failures - Add CentralCore methods to test node connectivity and connect/register remote nodes, with duplicate-name checks and node:connection:test event emission - Export NodeConnection types and APIs from @fusion/core for external consumers - Add comprehensive unit coverage for NodeConnection and CentralCore connection flows, and increase dashboard assignment test setup timeout to reduce build/test flakiness --- packages/core/src/central-core.test.ts | 146 ++++++++ packages/core/src/central-core.ts | 70 ++++ packages/core/src/index.ts | 8 + packages/core/src/node-connection.test.ts | 409 ++++++++++++++++++++++ packages/core/src/node-connection.ts | 288 +++++++++++++++ packages/dashboard/src/routes.test.ts | 2 +- 6 files changed, 922 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/node-connection.test.ts create mode 100644 packages/core/src/node-connection.ts diff --git a/packages/core/src/central-core.test.ts b/packages/core/src/central-core.test.ts index a23a52bb8..19a28c504 100644 --- a/packages/core/src/central-core.test.ts +++ b/packages/core/src/central-core.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CentralCore } from "./central-core.js"; +import { NodeConnection, type ConnectionResult } from "./node-connection.js"; import type { RegisteredProject, ProjectHealth, @@ -26,6 +27,7 @@ describe("CentralCore", () => { afterEach(async () => { await central.close(); vi.useRealTimers(); + vi.restoreAllMocks(); rmSync(tempDir, { recursive: true, force: true }); }); @@ -725,6 +727,150 @@ describe("CentralCore", () => { expect(emittedNodeId).toBe(node.id); expect(emittedStatus).toBe("online"); }); + + it("should test node connection and emit node:connection:test", async () => { + const connectionResult = { + success: true, + url: "http://remote.example:3000", + latencyMs: 12, + nodeInfo: { + name: "remote", + version: "1.0.0", + uptime: 5, + capabilities: ["executor"], + }, + }; + const testSpy = vi.spyOn(NodeConnection.prototype, "test").mockResolvedValue(connectionResult); + + let emittedResult: unknown; + central.on("node:connection:test", (result) => { + emittedResult = result; + }); + + const result = await central.testNodeConnection({ + host: "remote.example", + port: 3000, + apiKey: "secret", + }); + + expect(result).toEqual(connectionResult); + expect(emittedResult).toEqual(connectionResult); + expect(testSpy).toHaveBeenCalledWith({ + host: "remote.example", + port: 3000, + apiKey: "secret", + }); + }); + + it("should return failed testNodeConnection results", async () => { + const connectionResult: ConnectionResult = { + success: false, + url: "http://offline.example:3000", + error: { + type: "connection-refused", + message: "fetch failed: ECONNREFUSED", + }, + }; + vi.spyOn(NodeConnection.prototype, "test").mockResolvedValue(connectionResult); + + const result = await central.testNodeConnection({ + host: "offline.example", + port: 3000, + }); + + expect(result).toEqual(connectionResult); + }); + + it("should connect to remote node and register when test succeeds", async () => { + const connectionResult = { + success: true, + url: "http://remote.example:3000", + latencyMs: 10, + nodeInfo: { + name: "remote", + version: "1.0.0", + uptime: 30, + capabilities: ["executor"], + }, + }; + vi.spyOn(NodeConnection.prototype, "test").mockResolvedValue(connectionResult); + const registerSpy = vi.spyOn(central, "registerNode"); + const healthSpy = vi.spyOn(central, "checkNodeHealth").mockResolvedValue("online"); + + let emittedResult: unknown; + central.on("node:connection:test", (result) => { + emittedResult = result; + }); + + const output = await central.connectToRemoteNode({ + name: "remote-node", + host: "remote.example", + port: 3000, + apiKey: "secret", + maxConcurrent: 4, + }); + + expect(output.result).toEqual(connectionResult); + expect(output.node).toBeDefined(); + expect(output.node?.name).toBe("remote-node"); + expect(output.node?.type).toBe("remote"); + expect(output.node?.url).toBe("http://remote.example:3000"); + expect(emittedResult).toEqual(connectionResult); + expect(registerSpy).toHaveBeenCalledWith({ + name: "remote-node", + type: "remote", + url: "http://remote.example:3000", + apiKey: "secret", + maxConcurrent: 4, + }); + expect(healthSpy).toHaveBeenCalledWith(output.node!.id); + }); + + it("should reject duplicate node names before testing connection", async () => { + await central.registerNode({ name: "existing-node", type: "local" }); + + const testSpy = vi.spyOn(NodeConnection.prototype, "test"); + + await expect( + central.connectToRemoteNode({ + name: "existing-node", + host: "remote.example", + port: 3000, + }) + ).rejects.toThrow("Node already exists with name: existing-node"); + + expect(testSpy).not.toHaveBeenCalled(); + }); + + it("should return connection result without registration when test fails", async () => { + const connectionResult: ConnectionResult = { + success: false, + url: "http://offline.example:3000", + error: { + type: "timeout", + message: "Connection timed out after 10000ms", + }, + }; + vi.spyOn(NodeConnection.prototype, "test").mockResolvedValue(connectionResult); + const registerSpy = vi.spyOn(central, "registerNode"); + const healthSpy = vi.spyOn(central, "checkNodeHealth"); + + let emittedResult: unknown; + central.on("node:connection:test", (result) => { + emittedResult = result; + }); + + const output = await central.connectToRemoteNode({ + name: "offline-node", + host: "offline.example", + port: 3000, + }); + + expect(output).toEqual({ result: connectionResult }); + expect(registerSpy).not.toHaveBeenCalled(); + expect(healthSpy).not.toHaveBeenCalled(); + expect(emittedResult).toEqual(connectionResult); + }); }); describe("project health", () => { diff --git a/packages/core/src/central-core.ts b/packages/core/src/central-core.ts index a1efce310..24af65daa 100644 --- a/packages/core/src/central-core.ts +++ b/packages/core/src/central-core.ts @@ -47,6 +47,8 @@ import type { } from "./types.js"; import { CentralDatabase, toJson, toJsonNullable, fromJson } from "./central-db.js"; import { resolveGlobalDir } from "./global-settings.js"; +import { NodeConnection } from "./node-connection.js"; +import type { ConnectionOptions, ConnectionResult } from "./node-connection.js"; // ── Event Types ─────────────────────────────────────────────────────────── @@ -69,6 +71,8 @@ export interface CentralCoreEvents { "node:updated": [node: NodeConfig]; /** Emitted when node health status changes */ "node:health:changed": [node: NodeConfig]; + /** Emitted after a remote node connection test completes */ + "node:connection:test": [result: ConnectionResult]; /** Emitted when global concurrency state changes */ "concurrency:changed": [state: GlobalConcurrencyState]; } @@ -724,6 +728,72 @@ export class CentralCore extends EventEmitter { return nextStatus; } + /** + * Test connectivity to a remote Fusion node without registering it. + */ + async testNodeConnection(options: ConnectionOptions): Promise { + this.ensureInitialized(); + + const connection = new NodeConnection(); + const result = await connection.test(options); + this.emit("node:connection:test", result); + return result; + } + + /** + * Test a remote node connection and register it when successful. + */ + async connectToRemoteNode(input: { + name: string; + host: string; + port: number; + secure?: boolean; + apiKey?: string; + timeoutMs?: number; + maxConcurrent?: number; + }): Promise<{ result: ConnectionResult; node?: NodeConfig }> { + this.ensureInitialized(); + + const name = input.name.trim(); + if (!name) { + throw new Error("Node name is required"); + } + if (name.length > 64) { + throw new Error("Node name must be 1-64 characters"); + } + + const existingByName = await this.getNodeByName(name); + if (existingByName) { + throw new Error(`Node already exists with name: ${name}`); + } + + const connection = new NodeConnection(); + const result = await connection.test({ + host: input.host, + port: input.port, + secure: input.secure, + apiKey: input.apiKey, + timeoutMs: input.timeoutMs, + }); + + this.emit("node:connection:test", result); + + if (!result.success) { + return { result }; + } + + const node = await this.registerNode({ + name, + type: "remote", + url: result.url, + apiKey: input.apiKey, + maxConcurrent: input.maxConcurrent, + }); + await this.checkNodeHealth(node.id); + + return { result, node }; + } + /** * Assign a project to a node. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5efee7cfd..ea139bf5c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -136,6 +136,14 @@ export type { MissionStoreEvents, MissionSummary } from "./mission-store.js"; export { CentralCore } from "./central-core.js"; export type { CentralCoreEvents } from "./central-core.js"; export { CentralDatabase, createCentralDatabase } from "./central-db.js"; +export { NodeConnection } from "./node-connection.js"; +export type { + ConnectionErrorType, + ConnectionOptions, + ConnectionResult, + TestAndRegisterOptions, + TestAndRegisterResult, +} from "./node-connection.js"; export type { CentralActivityLogEntry, GlobalConcurrencyState, diff --git a/packages/core/src/node-connection.test.ts b/packages/core/src/node-connection.test.ts new file mode 100644 index 000000000..cea5ce7b5 --- /dev/null +++ b/packages/core/src/node-connection.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NodeConnection } from "./node-connection.js"; +import type { CentralCore } from "./central-core.js"; +import type { NodeConfig } from "./types.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + }, + }); +} + +describe("NodeConnection", () => { + let connection: NodeConnection; + let fetchMock: ReturnType; + + beforeEach(() => { + connection = new NodeConnection(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe("input validation", () => { + it("throws TypeError for empty host", async () => { + await expect(connection.test({ host: " ", port: 3000 })).rejects.toThrow(TypeError); + }); + + it("throws TypeError for port 0", async () => { + await expect(connection.test({ host: "127.0.0.1", port: 0 })).rejects.toThrow(TypeError); + }); + + it("throws TypeError for port greater than 65535", async () => { + await expect(connection.test({ host: "127.0.0.1", port: 70_000 })).rejects.toThrow(TypeError); + }); + + it("throws TypeError for negative timeout", async () => { + await expect( + connection.test({ + host: "127.0.0.1", + port: 3000, + timeoutMs: -1, + }) + ).rejects.toThrow(TypeError); + }); + }); + + describe("successful connections", () => { + it("connects to an IP address and returns metadata", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + status: "ok", + name: "Remote Node", + version: "1.2.3", + uptime: 123, + capabilities: ["executor"], + }) + ); + + const result = await connection.test({ + host: "192.168.1.100", + port: 3000, + }); + + expect(result.success).toBe(true); + expect(result.url).toBe("http://192.168.1.100:3000"); + expect(result.nodeInfo).toEqual({ + name: "Remote Node", + version: "1.2.3", + uptime: 123, + capabilities: ["executor"], + }); + expect(result.latencyMs).toBeTypeOf("number"); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + expect(fetchMock).toHaveBeenCalledWith("http://192.168.1.100:3000/api/health", { + method: "GET", + headers: undefined, + signal: expect.any(AbortSignal), + }); + }); + + it("connects to a hostname", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" })); + + const result = await connection.test({ + host: "my-server.local", + port: 8080, + }); + + expect(result.success).toBe(true); + expect(result.url).toBe("http://my-server.local:8080"); + expect(fetchMock).toHaveBeenCalledWith("http://my-server.local:8080/api/health", { + method: "GET", + headers: undefined, + signal: expect.any(AbortSignal), + }); + }); + + it("uses https when secure is true", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" })); + + const result = await connection.test({ + host: "secure.host", + port: 443, + secure: true, + }); + + expect(result.success).toBe(true); + expect(result.url).toBe("https://secure.host:443"); + expect(fetchMock).toHaveBeenCalledWith("https://secure.host:443/api/health", { + method: "GET", + headers: undefined, + signal: expect.any(AbortSignal), + }); + }); + + it("supports a reverse-proxy basePath", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" })); + + const result = await connection.test({ + host: "host", + port: 3000, + basePath: "/fusion", + }); + + expect(result.success).toBe(true); + expect(result.url).toBe("http://host:3000/fusion"); + expect(fetchMock).toHaveBeenCalledWith("http://host:3000/fusion/api/health", { + method: "GET", + headers: undefined, + signal: expect.any(AbortSignal), + }); + }); + + it("sends bearer auth when apiKey is provided", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" })); + + const result = await connection.test({ + host: "host", + port: 3000, + apiKey: "secret-key", + }); + + expect(result.success).toBe(true); + expect(fetchMock).toHaveBeenCalledWith("http://host:3000/api/health", { + method: "GET", + headers: { + Authorization: "Bearer secret-key", + }, + signal: expect.any(AbortSignal), + }); + }); + + it("applies defaults when optional health fields are missing", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" })); + + const result = await connection.test({ + host: "minimal.host", + port: 3000, + }); + + expect(result.success).toBe(true); + expect(result.nodeInfo).toEqual({ + name: "minimal.host", + version: "unknown", + uptime: 0, + capabilities: undefined, + }); + }); + }); + + describe("error handling", () => { + it("returns timeout classification for AbortError", async () => { + fetchMock.mockRejectedValueOnce(new DOMException("The operation was aborted", "AbortError")); + + const result = await connection.test({ + host: "host", + port: 3000, + timeoutMs: 2500, + }); + + expect(result).toMatchObject({ + success: false, + error: { + type: "timeout", + }, + }); + expect(result.error?.message).toContain("2500"); + }); + + it("returns dns-failure when fetch message contains ENOTFOUND", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed: getaddrinfo ENOTFOUND missing.local")); + + const result = await connection.test({ host: "missing.local", port: 3000 }); + + expect(result).toMatchObject({ + success: false, + error: { + type: "dns-failure", + }, + }); + }); + + it("returns connection-refused when fetch message contains ECONNREFUSED", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed: connect ECONNREFUSED 127.0.0.1:3000")); + + const result = await connection.test({ host: "127.0.0.1", port: 3000 }); + + expect(result).toMatchObject({ + success: false, + error: { + type: "connection-refused", + }, + }); + }); + + it("returns ssl-error for TLS certificate failures", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed: CERT_HAS_EXPIRED")); + + const result = await connection.test({ host: "secure.example", port: 443, secure: true }); + + expect(result).toMatchObject({ + success: false, + error: { + type: "ssl-error", + }, + }); + }); + + it("returns auth-failure for HTTP 401", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)); + + const result = await connection.test({ host: "host", port: 3000, apiKey: "bad" }); + + expect(result).toEqual({ + success: false, + url: "http://host:3000", + error: { + type: "auth-failure", + message: "Authentication failed (401) while testing http://host:3000", + statusCode: 401, + }, + }); + }); + + it("returns auth-failure for HTTP 403", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: "forbidden" }, 403)); + + const result = await connection.test({ host: "host", port: 3000, apiKey: "bad" }); + + expect(result).toEqual({ + success: false, + url: "http://host:3000", + error: { + type: "auth-failure", + message: "Authentication failed (403) while testing http://host:3000", + statusCode: 403, + }, + }); + }); + + it("returns unexpected-status for non-auth non-2xx responses", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500)); + + const result = await connection.test({ host: "host", port: 3000 }); + + expect(result).toEqual({ + success: false, + url: "http://host:3000", + error: { + type: "unexpected-status", + message: "Unexpected response status 500 while testing http://host:3000", + statusCode: 500, + }, + }); + }); + + it("returns not-fusion-node when response JSON lacks status field", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ healthy: true })); + + const result = await connection.test({ host: "host", port: 3000 }); + + expect(result).toMatchObject({ + success: false, + error: { + type: "not-fusion-node", + }, + }); + }); + + it("returns network-error for unknown failures", async () => { + fetchMock.mockRejectedValueOnce(new Error("socket hang up")); + + const result = await connection.test({ host: "host", port: 3000 }); + + expect(result).toEqual({ + success: false, + url: "http://host:3000", + error: { + type: "network-error", + message: "socket hang up", + }, + }); + }); + }); + + describe("testAndRegister", () => { + it("returns node when connection and registration both succeed", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ status: "ok", name: "Remote", version: "1.0.0", uptime: 42 }) + ); + + const node: NodeConfig = { + id: "node_123", + name: "remote-node", + type: "remote", + url: "http://remote.host:3000", + apiKey: "secret", + status: "offline", + maxConcurrent: 4, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }; + + const registerNodeMock = vi.fn().mockResolvedValue(node); + const checkNodeHealthMock = vi.fn().mockResolvedValue("online"); + const central = { + registerNode: registerNodeMock, + checkNodeHealth: checkNodeHealthMock, + } as unknown as CentralCore; + + const result = await connection.testAndRegister(central, { + name: "remote-node", + host: "remote.host", + port: 3000, + apiKey: "secret", + maxConcurrent: 4, + }); + + expect(result.success).toBe(true); + expect(result.node).toEqual(node); + expect(result.registrationError).toBeUndefined(); + expect(registerNodeMock).toHaveBeenCalledWith({ + name: "remote-node", + type: "remote", + url: "http://remote.host:3000", + apiKey: "secret", + maxConcurrent: 4, + }); + expect(checkNodeHealthMock).toHaveBeenCalledWith("node_123"); + }); + + it("returns registrationError when registration fails", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: "ok" })); + + const registerNodeMock = vi + .fn() + .mockRejectedValue(new Error("Node already exists with name: remote-node")); + const checkNodeHealthMock = vi.fn(); + const central = { + registerNode: registerNodeMock, + checkNodeHealth: checkNodeHealthMock, + } as unknown as CentralCore; + + const result = await connection.testAndRegister(central, { + name: "remote-node", + host: "remote.host", + port: 3000, + }); + + expect(result.success).toBe(true); + expect(result.node).toBeUndefined(); + expect(result.registrationError).toBe("Node already exists with name: remote-node"); + expect(checkNodeHealthMock).not.toHaveBeenCalled(); + }); + + it("skips registration when connection test fails", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed: connect ECONNREFUSED 127.0.0.1:3000")); + + const registerNodeMock = vi.fn(); + const checkNodeHealthMock = vi.fn(); + const central = { + registerNode: registerNodeMock, + checkNodeHealth: checkNodeHealthMock, + } as unknown as CentralCore; + + const result = await connection.testAndRegister(central, { + name: "remote-node", + host: "127.0.0.1", + port: 3000, + }); + + expect(result).toMatchObject({ + success: false, + error: { + type: "connection-refused", + }, + }); + expect(registerNodeMock).not.toHaveBeenCalled(); + expect(checkNodeHealthMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/node-connection.ts b/packages/core/src/node-connection.ts new file mode 100644 index 000000000..503ab0a53 --- /dev/null +++ b/packages/core/src/node-connection.ts @@ -0,0 +1,288 @@ +import { performance } from "node:perf_hooks"; +import type { NodeConfig } from "./types.js"; +import type { CentralCore } from "./central-core.js"; + +export type ConnectionErrorType = + | "timeout" + | "dns-failure" + | "connection-refused" + | "ssl-error" + | "auth-failure" + | "not-fusion-node" + | "unexpected-status" + | "network-error"; + +export interface ConnectionResult { + /** Whether the connection test succeeded */ + success: boolean; + /** Normalized URL (e.g., "http://192.168.1.100:3000") */ + url: string; + /** Latency in milliseconds for the successful health check */ + latencyMs?: number; + /** Remote node metadata (only present when success is true) */ + nodeInfo?: { + /** Node name reported by the remote Fusion instance */ + name: string; + /** Fusion version reported by the remote */ + version: string; + /** Uptime of the remote instance in seconds */ + uptime: number; + /** Capabilities supported by the remote node */ + capabilities?: string[]; + }; + /** Error details (only present when success is false) */ + error?: { + type: ConnectionErrorType; + message: string; + /** HTTP status code if applicable */ + statusCode?: number; + }; +} + +export interface ConnectionOptions { + /** IP address or hostname (e.g., "192.168.1.100" or "my-server.local") */ + host: string; + /** Port number (1-65535) */ + port: number; + /** Whether to use HTTPS (default: false) */ + secure?: boolean; + /** API key for authentication (optional) */ + apiKey?: string; + /** Connection timeout in milliseconds (default: 10000) */ + timeoutMs?: number; + /** Base path if the Fusion API is behind a reverse proxy prefix (default: "") */ + basePath?: string; +} + +export interface TestAndRegisterOptions extends ConnectionOptions { + name: string; + maxConcurrent?: number; +} + +export interface TestAndRegisterResult extends ConnectionResult { + node?: NodeConfig; + registrationError?: string; +} + +interface HealthPayload { + status?: unknown; + version?: unknown; + name?: unknown; + uptime?: unknown; + capabilities?: unknown; +} + +export class NodeConnection { + async test(options: ConnectionOptions): Promise { + this.validateInput(options); + + const timeoutMs = options.timeoutMs ?? 10_000; + const url = this.buildBaseUrl(options); + const healthUrl = `${url}/api/health`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + const startTime = performance.now(); + + try { + const response = await fetch(healthUrl, { + method: "GET", + headers: options.apiKey + ? { + Authorization: `Bearer ${options.apiKey}`, + } + : undefined, + signal: controller.signal, + }); + + if (response.status === 401 || response.status === 403) { + return { + success: false, + url, + error: { + type: "auth-failure", + message: `Authentication failed (${response.status}) while testing ${url}`, + statusCode: response.status, + }, + }; + } + + if (!response.ok) { + return { + success: false, + url, + error: { + type: "unexpected-status", + message: `Unexpected response status ${response.status} while testing ${url}`, + statusCode: response.status, + }, + }; + } + + let payload: HealthPayload; + try { + payload = (await response.json()) as HealthPayload; + } catch { + return { + success: false, + url, + error: { + type: "not-fusion-node", + message: `Endpoint ${url} did not return a valid Fusion health response`, + }, + }; + } + + if (!("status" in payload)) { + return { + success: false, + url, + error: { + type: "not-fusion-node", + message: `Endpoint ${url} is reachable but does not appear to be a Fusion node`, + }, + }; + } + + const latencyMs = Math.max(0, performance.now() - startTime); + const capabilities = Array.isArray(payload.capabilities) + ? payload.capabilities.filter((capability): capability is string => typeof capability === "string") + : undefined; + + return { + success: true, + url, + latencyMs, + nodeInfo: { + name: typeof payload.name === "string" && payload.name.trim().length > 0 ? payload.name : options.host, + version: typeof payload.version === "string" && payload.version.trim().length > 0 ? payload.version : "unknown", + uptime: typeof payload.uptime === "number" && Number.isFinite(payload.uptime) ? payload.uptime : 0, + capabilities, + }, + }; + } catch (error) { + return { + success: false, + url, + error: this.classifyError(error, timeoutMs), + }; + } finally { + clearTimeout(timeout); + } + } + + async testAndRegister( + central: CentralCore, + options: TestAndRegisterOptions + ): Promise { + const { name, maxConcurrent, ...connectionOptions } = options; + const result = await this.test(connectionOptions); + + if (!result.success) { + return result; + } + + try { + const node = await central.registerNode({ + name, + type: "remote", + url: result.url, + apiKey: connectionOptions.apiKey, + maxConcurrent, + }); + await central.checkNodeHealth(node.id); + return { + ...result, + node, + }; + } catch (error) { + return { + ...result, + registrationError: error instanceof Error ? error.message : String(error), + }; + } + } + + private validateInput(options: ConnectionOptions): void { + if (typeof options.host !== "string" || options.host.trim().length === 0) { + throw new TypeError("Connection host must be a non-empty string"); + } + + if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65_535) { + throw new TypeError(`Connection port must be an integer between 1 and 65535: ${String(options.port)}`); + } + + if ( + options.timeoutMs !== undefined && + (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) + ) { + throw new TypeError(`Connection timeoutMs must be greater than 0: ${String(options.timeoutMs)}`); + } + } + + private buildBaseUrl(options: ConnectionOptions): string { + const protocol = options.secure ? "https" : "http"; + const basePath = this.normalizeBasePath(options.basePath); + return `${protocol}://${options.host}:${options.port}${basePath}`; + } + + private normalizeBasePath(basePath?: string): string { + if (basePath === undefined) { + return ""; + } + + const trimmed = basePath.trim(); + if (!trimmed || trimmed === "/") { + return ""; + } + + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash; + } + + private classifyError( + error: unknown, + timeoutMs: number + ): { type: ConnectionErrorType; message: string; statusCode?: number } { + if (error instanceof Error && error.name === "AbortError") { + return { + type: "timeout", + message: `Connection timed out after ${timeoutMs}ms`, + }; + } + + const message = error instanceof Error ? error.message : String(error); + const lowered = message.toLowerCase(); + + if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) { + return { + type: "dns-failure", + message, + }; + } + + if (lowered.includes("econnrefused")) { + return { + type: "connection-refused", + message, + }; + } + + if ( + message.includes("CERT") || + message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE") || + lowered.includes("self signed") + ) { + return { + type: "ssl-error", + message, + }; + } + + return { + type: "network-error", + message, + }; + } +} diff --git a/packages/dashboard/src/routes.test.ts b/packages/dashboard/src/routes.test.ts index e702c8f3b..e9ee0eadb 100644 --- a/packages/dashboard/src/routes.test.ts +++ b/packages/dashboard/src/routes.test.ts @@ -2029,7 +2029,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => { updateTask: vi.fn(), listTasks: vi.fn().mockResolvedValue([]), } as any); - }); + }, 30_000); afterEach(() => { rmSync(tempDir, { recursive: true, force: true });