feat(FN-1803): add nodeId support to project registration and browse-directory
- Add nodeId parameter to registerProject in CentralCore for multi-project tracking - Update POST /api/projects route to accept and persist nodeId - Update browse-directory route to filter by nodeId - Add nodeId field to frontend API wrappers for registerProject and browseDirectory - Add comprehensive tests for nodeId in project registration and browse-directory routes
This commit is contained in:
@@ -3013,6 +3013,7 @@ export interface ProjectCreateInput {
|
||||
name: string;
|
||||
path: string;
|
||||
isolationMode?: "in-process" | "child-process";
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
/** Node information returned by node endpoints */
|
||||
@@ -3244,12 +3245,23 @@ export interface BrowseDirectoryResult {
|
||||
entries: Array<{ name: string; path: string; hasChildren: boolean }>;
|
||||
}
|
||||
|
||||
export function browseDirectory(path?: string, showHidden?: boolean): Promise<BrowseDirectoryResult> {
|
||||
export function browseDirectory(
|
||||
path?: string,
|
||||
showHidden?: boolean,
|
||||
nodeId?: string,
|
||||
localNodeId?: string,
|
||||
): Promise<BrowseDirectoryResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set("path", path);
|
||||
if (showHidden) params.set("showHidden", "true");
|
||||
if (nodeId) params.set("nodeId", nodeId);
|
||||
const qs = params.toString();
|
||||
return api<BrowseDirectoryResult>(`/browse-directory${qs ? `?${qs}` : ""}`);
|
||||
const fullPath = `/browse-directory${qs ? `?${qs}` : ""}`;
|
||||
// If nodeId is for a remote node, route through proxy
|
||||
if (nodeId && nodeId !== localNodeId) {
|
||||
return proxyApi<BrowseDirectoryResult>(fullPath, { nodeId, localNodeId });
|
||||
}
|
||||
return api<BrowseDirectoryResult>(fullPath);
|
||||
}
|
||||
|
||||
/** Register a new project */
|
||||
|
||||
339
packages/dashboard/src/__tests__/browse-directory-routes.test.ts
Normal file
339
packages/dashboard/src/__tests__/browse-directory-routes.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
// Mock node:fs for route handler tests that check path existence
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
// Use vi.hoisted() for mock functions that need to be accessible in hoisted vi.mock calls
|
||||
const {
|
||||
mockInit,
|
||||
mockClose,
|
||||
mockListNodes,
|
||||
mockGetNode,
|
||||
} = vi.hoisted(() => ({
|
||||
mockInit: vi.fn().mockResolvedValue(undefined),
|
||||
mockClose: vi.fn().mockResolvedValue(undefined),
|
||||
mockListNodes: vi.fn().mockResolvedValue([]),
|
||||
mockGetNode: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
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,
|
||||
getNode: mockGetNode,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
import { browseDirectory } from "../../app/api.js";
|
||||
|
||||
function mockFetchResponse(
|
||||
ok: boolean,
|
||||
body: unknown,
|
||||
status = ok ? 200 : 500,
|
||||
contentType = "application/json"
|
||||
) {
|
||||
const bodyText = JSON.stringify(body);
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? contentType : null,
|
||||
},
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(bodyText),
|
||||
arrayBuffer: () => Promise.resolve(Buffer.from(bodyText)),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
class MockStoreForRoutes extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-944";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-944/.fusion";
|
||||
}
|
||||
|
||||
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() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
describe("GET /api/browse-directory route handler", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.fetch = vi.fn();
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
mockGetNode.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("local node (no nodeId)", () => {
|
||||
it("returns local filesystem entries when no nodeId is provided", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
const res = await request(app, "GET", "/api/browse-directory");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// No CentralCore calls when nodeId is not provided (direct filesystem access)
|
||||
expect(mockInit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("local node (nodeId matches local node)", () => {
|
||||
it("returns local filesystem entries when nodeId matches local node", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
// Mock local node
|
||||
mockListNodes.mockResolvedValue([
|
||||
{
|
||||
id: "node-local-1",
|
||||
name: "Local Node",
|
||||
type: "local",
|
||||
url: "http://localhost:4040",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(app, "GET", "/api/browse-directory?nodeId=node-local-1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockListNodes).toHaveBeenCalled();
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote node (nodeId is remote)", () => {
|
||||
it("proxies request to remote node", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
// Mock remote node
|
||||
mockGetNode.mockResolvedValue({
|
||||
id: "node-remote-1",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
apiKey: undefined,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// Mock remote fetch response
|
||||
const remoteResponse = {
|
||||
currentPath: "/home",
|
||||
parentPath: "/",
|
||||
entries: [],
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) => name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
entries: () => [],
|
||||
},
|
||||
json: () => Promise.resolve(remoteResponse),
|
||||
arrayBuffer: () => Promise.resolve(Buffer.from(JSON.stringify(remoteResponse))),
|
||||
});
|
||||
});
|
||||
|
||||
const res = await request(app, "GET", "/api/browse-directory?nodeId=node-remote-1&path=/home");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockGetNode).toHaveBeenCalledWith("node-remote-1");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("http://remote:4040"),
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Authorization header when apiKey is set", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
// Mock remote node with apiKey
|
||||
mockGetNode.mockResolvedValue({
|
||||
id: "node-remote-1",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
apiKey: "secret-key",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(true, { currentPath: "/", parentPath: null, entries: [] }));
|
||||
|
||||
await request(app, "GET", "/api/browse-directory?nodeId=node-remote-1");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer secret-key",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 when node not found", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
// No local nodes, getNode returns null
|
||||
mockGetNode.mockResolvedValue(null);
|
||||
|
||||
const res = await request(app, "GET", "/api/browse-directory?nodeId=nonexistent");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when node has no URL", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
// Node exists but has no URL
|
||||
mockGetNode.mockResolvedValue({
|
||||
id: "node-no-url",
|
||||
name: "No URL Node",
|
||||
type: "remote",
|
||||
url: undefined,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const res = await request(app, "GET", "/api/browse-directory?nodeId=node-no-url");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 502 on remote fetch error", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
mockGetNode.mockResolvedValue({
|
||||
id: "node-remote-1",
|
||||
name: "Remote Node",
|
||||
type: "remote",
|
||||
url: "http://remote:4040",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// Mock fetch throwing TypeError (network error)
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
|
||||
|
||||
const res = await request(app, "GET", "/api/browse-directory?nodeId=node-remote-1");
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("browseDirectory API function", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("sends nodeId parameter when provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
mockFetchResponse(true, { currentPath: "/", parentPath: null, entries: [] })
|
||||
);
|
||||
|
||||
await browseDirectory("/home", false, "node-remote-1", "node-local-1");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/proxy/node-remote-1/browse-directory"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("calls directly without proxy when nodeId matches localNodeId", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
mockFetchResponse(true, { currentPath: "/", parentPath: null, entries: [] })
|
||||
);
|
||||
|
||||
await browseDirectory("/home", false, "node-local-1", "node-local-1");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/browse-directory"),
|
||||
expect.not.objectContaining({ nodeId: expect.anything() })
|
||||
);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("/proxy/"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include nodeId in direct calls", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
mockFetchResponse(true, { currentPath: "/", parentPath: null, entries: [] })
|
||||
);
|
||||
|
||||
await browseDirectory("/home");
|
||||
|
||||
const calls = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const url: string = calls[0][0];
|
||||
expect(url).not.toContain("nodeId");
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ const {
|
||||
mockClose,
|
||||
mockReconcileProjectStatuses,
|
||||
mockGetOrCreateProjectStore,
|
||||
mockListNodes,
|
||||
mockGetNode,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListProjects: vi.fn().mockResolvedValue([]),
|
||||
mockGetProject: vi.fn().mockResolvedValue(null),
|
||||
@@ -77,6 +79,8 @@ const {
|
||||
mockReconcileProjectStatuses: vi.fn().mockResolvedValue([]),
|
||||
// Mock store registry - can be configured per-test to return specific stores per project ID
|
||||
mockGetOrCreateProjectStore: vi.fn(),
|
||||
mockListNodes: vi.fn().mockResolvedValue([]),
|
||||
mockGetNode: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
@@ -96,6 +100,8 @@ vi.mock("@fusion/core", async () => {
|
||||
getGlobalConcurrencyState: mockGetGlobalConcurrencyState,
|
||||
updateGlobalConcurrency: mockUpdateGlobalConcurrency,
|
||||
reconcileProjectStatuses: mockReconcileProjectStatuses,
|
||||
listNodes: mockListNodes,
|
||||
getNode: mockGetNode,
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -587,6 +593,31 @@ describe("POST /api/projects route handler", () => {
|
||||
expect(mockUpdateProject).toHaveBeenCalledWith("proj_test123", { status: "active" });
|
||||
expect((res.body as any).status).toBe("active");
|
||||
});
|
||||
|
||||
it("passes nodeId to registerProject when provided", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/projects",
|
||||
JSON.stringify({
|
||||
name: "Remote Project",
|
||||
path: "/remote/path",
|
||||
nodeId: "node-remote-1",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockRegisterProject).toHaveBeenCalledWith({
|
||||
name: "Remote Project",
|
||||
path: "/remote/path",
|
||||
isolationMode: "in-process",
|
||||
nodeId: "node-remote-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/projects route handler", () => {
|
||||
|
||||
@@ -13185,11 +13185,72 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
/**
|
||||
* GET /api/browse-directory
|
||||
* Browse filesystem directories for the directory picker.
|
||||
* Query: { path?: string, showHidden?: "true" }
|
||||
* Query: { path?: string, showHidden?: "true", nodeId?: string }
|
||||
* Returns: { currentPath: string, parentPath: string | null, entries: Array<{ name: string, path: string, hasChildren: boolean }> }
|
||||
*/
|
||||
router.get("/browse-directory", async (req, res) => {
|
||||
try {
|
||||
const nodeId = req.query.nodeId as string | undefined;
|
||||
|
||||
// Node-aware proxying: route to remote node if nodeId is provided and not local
|
||||
if (nodeId) {
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const localNodes = await central.listNodes();
|
||||
const localNode = localNodes.find((n: any) => n.type === "local");
|
||||
|
||||
if (localNode && localNode.id === nodeId) {
|
||||
// Local node — fall through to existing filesystem logic below
|
||||
await central.close();
|
||||
} else {
|
||||
// Remote node — look up node config and proxy directly
|
||||
const node = await central.getNode(nodeId);
|
||||
await central.close();
|
||||
|
||||
if (!node) {
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
if (!node.url) {
|
||||
throw badRequest("Node has no URL configured");
|
||||
}
|
||||
|
||||
const queryString = req.url.split('?').slice(1).join('?');
|
||||
const targetUrl = `${node.url.replace(/\/$/, '')}/api/browse-directory${queryString ? '?' + queryString : ''}`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (node.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${node.apiKey}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const proxyRes = await fetch(targetUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
const body = Buffer.from(await proxyRes.arrayBuffer());
|
||||
// Filter hop-by-hop headers
|
||||
const skipHeaders = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade"]);
|
||||
for (const [key, value] of proxyRes.headers.entries()) {
|
||||
if (!skipHeaders.has(key.toLowerCase())) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
res.status(proxyRes.status);
|
||||
res.send(body);
|
||||
} catch (fetchErr: any) {
|
||||
if (fetchErr.name === "AbortError" || fetchErr.code === "ETIMEDOUT") {
|
||||
throw new ApiError(504, `Remote node timeout: ${fetchErr.message}`);
|
||||
}
|
||||
throw new ApiError(502, `Remote node error: ${fetchErr.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Local node logic
|
||||
const { resolve, dirname, join } = await import("node:path");
|
||||
const { readdir, stat } = await import("node:fs/promises");
|
||||
|
||||
@@ -13288,7 +13349,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.post("/projects", async (req, res) => {
|
||||
try {
|
||||
const { name, path, isolationMode = "in-process" } = req.body;
|
||||
const { name, path, isolationMode = "in-process", nodeId } = req.body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
throw badRequest("name is required and must be a non-empty string");
|
||||
@@ -13316,6 +13377,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
name: name.trim(),
|
||||
path: path.trim(),
|
||||
isolationMode,
|
||||
nodeId,
|
||||
});
|
||||
|
||||
// Activate the project (registration sets it to 'initializing')
|
||||
|
||||
Reference in New Issue
Block a user