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:
Fusion
2026-04-16 04:34:49 -07:00
committed by gsxdsm
parent 3c98c4353e
commit 6177cfb5f7
6 changed files with 486 additions and 6 deletions

View File

@@ -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 */

View 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");
});
});

View File

@@ -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", () => {

View File

@@ -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')