feat(FN-3505): add node onboarding mapping flow with asset-gated bundling

- Add onboarding project-mapping API contracts and dashboard UI wiring in AddNodeModal/NodesView
- Persist node-to-project mappings with rollback-safe failure handling and expanded hook/API test coverage
- Document node onboarding mapping behavior in architecture and multi-project docs
- Add tsup bundle asset gates for openclaw bridge and droid runtime, with changesets and bundle output tests

Fusion-Task-Id: FN-3505
This commit is contained in:
Fusion
2026-05-07 19:29:52 -07:00
committed by gsxdsm
parent 9743dab1d9
commit faaa99e768
13 changed files with 453 additions and 25 deletions

View File

@@ -758,6 +758,12 @@ Custom-provider settings routes are registered in `register-custom-provider-rout
This API surface is intentionally separate from `projects.nodeId` (runtime host placement metadata) and from task-level routing defaults (`defaultNodeId` / `Task.nodeId`). This API surface is intentionally separate from `projects.nodeId` (runtime host placement metadata) and from task-level routing defaults (`defaultNodeId` / `Task.nodeId`).
Dashboard node onboarding (`AddNodeModal``useNodes.register`) uses a two-phase flow:
1. Register node metadata first via `POST /api/nodes`.
2. Persist selected project↔node path mappings with per-project `PUT /api/projects/:id/path-mappings/:nodeId` upserts.
The client treats mapping persistence as part of onboarding success. If mapping writes fail after node creation, onboarding attempts rollback via `DELETE /api/nodes/:id` and refreshes node state to avoid a silent half-configured node.
### Node settings sync and update-check endpoints ### Node settings sync and update-check endpoints
| Method | Path | Description | | Method | Path | Description |

View File

@@ -115,6 +115,19 @@ Dashboard and node workflows should use dedicated mapping endpoints rather than
These APIs persist/read `projectNodePathMappings` (`projectId` + `nodeId` key). They do **not** assign runtime hosting, and they do **not** change task routing defaults. These APIs persist/read `projectNodePathMappings` (`projectId` + `nodeId` key). They do **not** assign runtime hosting, and they do **not** change task routing defaults.
### Node onboarding path-capture flow
When adding a node from the dashboard, onboarding now supports attaching already-registered projects and capturing a node-specific absolute path for each selected project.
- Step 1: register the node (`POST /api/nodes`)
- Step 2: upsert one `projectNodePathMappings` record per selected project (`PUT /api/projects/:id/path-mappings/:nodeId`)
This onboarding mapping capture is intentionally separate from:
- `projects.nodeId` (runtime host-node assignment)
- `projects.path` / `ProjectInfo.path` (canonical registered project path)
So node onboarding records where a given node can access a project on disk, without changing which node hosts the runtime or task-routing defaults.
### Runtime placement (`projects.nodeId`) ### Runtime placement (`projects.nodeId`)
`ProjectManager` uses project registration data plus isolation mode to pick runtime type: `ProjectManager` uses project registration data plus isolation mode to pick runtime type:

View File

@@ -10,21 +10,25 @@ import {
fetchNodeSettingsSyncStatus, fetchNodeSettingsSyncStatus,
syncNodeAuth, syncNodeAuth,
fetchNodeProjectPathMappings, fetchNodeProjectPathMappings,
persistNodeProjectPathMappings,
} from "../api-node"; } from "../api-node";
import * as apiModule from "../api"; import * as apiModule from "../api";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
proxyApi: vi.fn(), proxyApi: vi.fn(),
api: vi.fn(), api: vi.fn(),
upsertProjectPathMapping: vi.fn(),
})); }));
const mockProxyApi = vi.mocked(apiModule.proxyApi); const mockProxyApi = vi.mocked(apiModule.proxyApi);
const mockApi = vi.mocked(apiModule.api); const mockApi = vi.mocked(apiModule.api);
const mockUpsertProjectPathMapping = vi.mocked(apiModule.upsertProjectPathMapping);
describe("api-node", () => { describe("api-node", () => {
beforeEach(() => { beforeEach(() => {
mockProxyApi.mockReset(); mockProxyApi.mockReset();
mockApi.mockReset(); mockApi.mockReset();
mockUpsertProjectPathMapping.mockReset();
}); });
describe("fetchRemoteNodeHealth", () => { describe("fetchRemoteNodeHealth", () => {
@@ -231,6 +235,45 @@ describe("api-node", () => {
}); });
}); });
describe("persistNodeProjectPathMappings", () => {
it("upserts one mapping per selected project", async () => {
mockUpsertProjectPathMapping
.mockResolvedValueOnce({ projectId: "proj-1", nodeId: "node-1", path: "/node/proj-1", createdAt: "t", updatedAt: "t" })
.mockResolvedValueOnce({ projectId: "proj-2", nodeId: "node-1", path: "/node/proj-2", createdAt: "t", updatedAt: "t" });
const result = await persistNodeProjectPathMappings("node-1", [
{ projectId: "proj-1", path: "/node/proj-1" },
{ projectId: "proj-2", path: "/node/proj-2" },
]);
expect(mockUpsertProjectPathMapping).toHaveBeenNthCalledWith(1, "proj-1", "node-1", "/node/proj-1");
expect(mockUpsertProjectPathMapping).toHaveBeenNthCalledWith(2, "proj-2", "node-1", "/node/proj-2");
expect(result).toHaveLength(2);
});
it("preserves encoded project ids through delegated helper calls", async () => {
mockUpsertProjectPathMapping.mockResolvedValueOnce({
projectId: "proj/1+2",
nodeId: "node/1+2",
path: "/path",
createdAt: "t",
updatedAt: "t",
});
await persistNodeProjectPathMappings("node/1+2", [{ projectId: "proj/1+2", path: "/path" }]);
expect(mockUpsertProjectPathMapping).toHaveBeenCalledWith("proj/1+2", "node/1+2", "/path");
});
it("propagates the first upsert failure", async () => {
mockUpsertProjectPathMapping.mockRejectedValueOnce(new Error("mapping failed"));
await expect(
persistNodeProjectPathMappings("node-1", [{ projectId: "proj-1", path: "/node/proj-1" }]),
).rejects.toThrow("mapping failed");
});
});
describe("error handling", () => { describe("error handling", () => {
it("propagates errors from proxyApi", async () => { it("propagates errors from proxyApi", async () => {
mockProxyApi.mockRejectedValueOnce(new Error("Network error")); mockProxyApi.mockRejectedValueOnce(new Error("Network error"));

View File

@@ -3,9 +3,9 @@
* All functions route through /api/proxy/:nodeId/... when a remote node is targeted. * All functions route through /api/proxy/:nodeId/... when a remote node is targeted.
*/ */
import type { ProjectInfo } from "./api"; import type { NodeProjectMappingInput, ProjectInfo } from "./api";
import type { ProjectHealth, ProjectNodePathMapping, Task } from "@fusion/core"; import type { ProjectHealth, ProjectNodePathMapping, Task } from "@fusion/core";
import { api, proxyApi } from "./api"; import { api, proxyApi, upsertProjectPathMapping } from "./api";
/** Health information for a remote node */ /** Health information for a remote node */
export interface RemoteNodeHealth { export interface RemoteNodeHealth {
@@ -125,3 +125,13 @@ export async function syncNodeAuth(nodeId: string): Promise<NodeAuthSyncResult>
export async function fetchNodeProjectPathMappings(nodeId: string): Promise<ProjectNodePathMapping[]> { export async function fetchNodeProjectPathMappings(nodeId: string): Promise<ProjectNodePathMapping[]> {
return api<ProjectNodePathMapping[]>(`/nodes/${encodeURIComponent(nodeId)}/path-mappings`); return api<ProjectNodePathMapping[]>(`/nodes/${encodeURIComponent(nodeId)}/path-mappings`);
} }
/** Persist one mapping per selected project for a newly-created node. */
export async function persistNodeProjectPathMappings(
nodeId: string,
projectMappings: NodeProjectMappingInput[],
): Promise<ProjectNodePathMapping[]> {
return Promise.all(
projectMappings.map(({ projectId, path }) => upsertProjectPathMapping(projectId, nodeId, path)),
);
}

View File

@@ -5,3 +5,4 @@
* while implementation lives under `app/api/*` modules. * while implementation lives under `app/api/*` modules.
*/ */
export * from "./api/legacy"; export * from "./api/legacy";
export * from "./api-node";

View File

@@ -5542,6 +5542,22 @@ export interface NodeCreateInput {
dockerConfig?: DockerNodeConfigInfo; dockerConfig?: DockerNodeConfigInfo;
} }
/** Input for assigning a project path for a specific node during onboarding. */
export interface NodeProjectMappingInput {
projectId: string;
path: string;
}
/**
* Node onboarding payload used by dashboard UI.
*
* `projectMappings` is intentionally separate from `ProjectInfo.path` and `projects.nodeId`.
* It captures node-specific filesystem paths for selected existing projects.
*/
export interface NodeOnboardingInput extends NodeCreateInput {
projectMappings: NodeProjectMappingInput[];
}
/** Input for updating an existing node */ /** Input for updating an existing node */
export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent" | "dockerConfig">> & { export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent" | "dockerConfig">> & {
status?: NodeStatus; status?: NodeStatus;

View File

@@ -149,6 +149,34 @@
gap: var(--space-sm); gap: var(--space-sm);
} }
.add-node-modal__projects {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.add-node-modal__projects-title {
margin: 0;
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
}
.add-node-modal__project-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.add-node-modal__project-card {
padding: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.add-node-modal__project-toggle {
margin: 0;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.add-node-modal { .add-node-modal {
width: calc(100vw - (var(--space-md) * 2)); width: calc(100vw - (var(--space-md) * 2));
@@ -171,4 +199,8 @@
.add-node-modal__row { .add-node-modal__row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.add-node-modal__project-card {
padding: var(--space-md);
}
} }

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { NodeProjectMappingInput, ProjectInfo } from "../api";
import { validateProjectPath } from "../utils/projectDetection";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import "./AddNodeModal.css"; import "./AddNodeModal.css";
@@ -9,6 +11,7 @@ export interface AddNodeInput {
url?: string; url?: string;
apiKey?: string; apiKey?: string;
maxConcurrent: number; maxConcurrent: number;
projectMappings: NodeProjectMappingInput[];
apiKeyMode?: "auto-generate" | "provide"; apiKeyMode?: "auto-generate" | "provide";
extraClis?: Array<"claude-cli" | "droid-cli">; extraClis?: Array<"claude-cli" | "droid-cli">;
persistentStorage?: boolean; persistentStorage?: boolean;
@@ -30,19 +33,21 @@ interface AddNodeModalProps {
onClose: () => void; onClose: () => void;
onSubmit: (input: AddNodeInput) => Promise<void>; onSubmit: (input: AddNodeInput) => Promise<void>;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
projects: ProjectInfo[];
} }
interface FormErrors { interface FormErrors {
name?: string; name?: string;
url?: string; url?: string;
maxConcurrent?: string; maxConcurrent?: string;
projectMappings: Record<string, string>;
} }
const MAX_CONCURRENT_MIN = 1; const MAX_CONCURRENT_MIN = 1;
const MAX_CONCURRENT_MAX = 10; const MAX_CONCURRENT_MAX = 10;
function validateInput(input: AddNodeInput): FormErrors { function validateInput(input: AddNodeInput): FormErrors {
const errors: FormErrors = {}; const errors: FormErrors = { projectMappings: {} };
if (!input.name.trim()) { if (!input.name.trim()) {
errors.name = "Name is required"; errors.name = "Name is required";
@@ -56,10 +61,17 @@ function validateInput(input: AddNodeInput): FormErrors {
errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`; errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`;
} }
for (const mapping of input.projectMappings) {
const validation = validateProjectPath(mapping.path);
if (!validation.valid) {
errors.projectMappings[mapping.projectId] = validation.error ?? "Path is invalid";
}
}
return errors; return errors;
} }
export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeModalProps) { export function AddNodeModal({ isOpen, onClose, onSubmit, addToast, projects }: AddNodeModalProps) {
useMobileScrollLock(isOpen); useMobileScrollLock(isOpen);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [type, setType] = useState<"local" | "remote">("local"); const [type, setType] = useState<"local" | "remote">("local");
@@ -67,7 +79,8 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState(2); const [maxConcurrent, setMaxConcurrent] = useState(2);
const [apiKeyMode, setApiKeyMode] = useState<"auto-generate" | "provide">("auto-generate"); const [apiKeyMode, setApiKeyMode] = useState<"auto-generate" | "provide">("auto-generate");
const [errors, setErrors] = useState<FormErrors>({}); const [selectedProjectPaths, setSelectedProjectPaths] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<FormErrors>({ projectMappings: {} });
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
@@ -77,7 +90,8 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
setApiKey(""); setApiKey("");
setMaxConcurrent(2); setMaxConcurrent(2);
setApiKeyMode("auto-generate"); setApiKeyMode("auto-generate");
setErrors({}); setSelectedProjectPaths({});
setErrors({ projectMappings: {} });
setIsSubmitting(false); setIsSubmitting(false);
}, []); }, []);
@@ -113,7 +127,8 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
apiKey: type === "remote" && apiKeyMode === "provide" ? apiKey || undefined : undefined, apiKey: type === "remote" && apiKeyMode === "provide" ? apiKey || undefined : undefined,
maxConcurrent, maxConcurrent,
apiKeyMode, apiKeyMode,
}), [apiKey, apiKeyMode, maxConcurrent, name, type, url]); projectMappings: Object.entries(selectedProjectPaths).map(([projectId, path]) => ({ projectId, path: path.trim() })),
}), [apiKey, apiKeyMode, maxConcurrent, name, selectedProjectPaths, type, url]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (isSubmitting) return; if (isSubmitting) return;
@@ -121,7 +136,12 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
const validationErrors = validateInput(input); const validationErrors = validateInput(input);
setErrors(validationErrors); setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) { if (
validationErrors.name
|| validationErrors.url
|| validationErrors.maxConcurrent
|| Object.keys(validationErrors.projectMappings).length > 0
) {
return; return;
} }
@@ -139,6 +159,20 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
} }
}, [addToast, closeModal, input, isSubmitting, onSubmit]); }, [addToast, closeModal, input, isSubmitting, onSubmit]);
const toggleProjectSelection = (project: ProjectInfo) => {
setSelectedProjectPaths((current) => {
if (project.id in current) {
const { [project.id]: _removed, ...remaining } = current;
return remaining;
}
return { ...current, [project.id]: project.path };
});
};
const updateProjectPath = (projectId: string, path: string) => {
setSelectedProjectPaths((current) => ({ ...current, [projectId]: path }));
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -253,6 +287,49 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeMod
{errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>} {errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>}
</label> </label>
<section className="add-node-modal__projects" aria-label="Project path mappings">
<h4 className="add-node-modal__projects-title">Attach Existing Projects</h4>
<p className="add-node-modal__hint">Select existing projects to run on this node and provide the node-specific absolute path for each one.</p>
{projects.length === 0 ? (
<p className="add-node-modal__hint">No projects are currently registered.</p>
) : (
<div className="add-node-modal__project-list">
{projects.map((project) => {
const selected = project.id in selectedProjectPaths;
const error = errors.projectMappings[project.id];
return (
<div key={project.id} className="card add-node-modal__project-card">
<label className="checkbox-label add-node-modal__project-toggle">
<input
type="checkbox"
checked={selected}
onChange={() => toggleProjectSelection(project)}
disabled={isSubmitting}
/>
<span>{project.name}</span>
</label>
{selected && (
<label className="add-node-modal__field">
<span>Path on this node</span>
<input
className="input"
type="text"
value={selectedProjectPaths[project.id] ?? ""}
onChange={(event) => updateProjectPath(project.id, event.target.value)}
disabled={isSubmitting}
placeholder="/absolute/path/to/project"
aria-invalid={Boolean(error)}
/>
{error && <span className="form-error add-node-modal__error">{error}</span>}
</label>
)}
</div>
);
})}
</div>
)}
</section>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">

View File

@@ -32,7 +32,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
patchDockerConfig, patchDockerConfig,
fetchDockerDiff, fetchDockerDiff,
} = useNodes(); } = useNodes();
const { projects } = useProjects(); const { projects, refresh: refreshProjects } = useProjects();
const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync(); const { syncStatusMap, pushSettings, pullSettings, syncAuth, trackNode, getAuthSyncState, getAuthProviders } = useNodeSettingsSync();
const { const {
dockerNodes, dockerNodes,
@@ -74,7 +74,8 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
const handleRegister = useCallback(async (input: AddNodeInput) => { const handleRegister = useCallback(async (input: AddNodeInput) => {
await register(input); await register(input);
}, [register]); await refreshProjects();
}, [refreshProjects, register]);
const handleCreateDockerNode = useCallback(async (input: ManagedDockerNodeInput) => { const handleCreateDockerNode = useCallback(async (input: ManagedDockerNodeInput) => {
try { try {
@@ -249,6 +250,7 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
onClose={() => setAddModalOpen(false)} onClose={() => setAddModalOpen(false)}
onSubmit={handleRegister} onSubmit={handleRegister}
addToast={addToast} addToast={addToast}
projects={projects}
/> />
<DockerNodeOnboardingModal <DockerNodeOnboardingModal

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AddNodeModal } from "../AddNodeModal"; import { AddNodeModal } from "../AddNodeModal";
import type { NodeInfo } from "../../api";
describe("AddNodeModal", () => { describe("AddNodeModal", () => {
const defaultProps = { const defaultProps = {
@@ -9,6 +8,24 @@ describe("AddNodeModal", () => {
onClose: vi.fn(), onClose: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined), onSubmit: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(), addToast: vi.fn(),
projects: [
{
id: "proj-1",
name: "Project One",
path: "/workspace/project-one",
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "proj-2",
name: "Project Two",
path: "/workspace/project-two",
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
}; };
beforeEach(() => { beforeEach(() => {
@@ -92,6 +109,7 @@ describe("AddNodeModal", () => {
url: undefined, url: undefined,
apiKey: undefined, apiKey: undefined,
maxConcurrent: 2, maxConcurrent: 2,
projectMappings: [],
})); }));
expect(defaultProps.addToast).toHaveBeenCalledWith('Node "Test Node" registered', "success"); expect(defaultProps.addToast).toHaveBeenCalledWith('Node "Test Node" registered', "success");
expect(defaultProps.onClose).toHaveBeenCalled(); expect(defaultProps.onClose).toHaveBeenCalled();
@@ -249,7 +267,74 @@ describe("AddNodeModal", () => {
url: "https://node.example.com", url: "https://node.example.com",
apiKey: "secret-key", apiKey: "secret-key",
maxConcurrent: 2, maxConcurrent: 2,
projectMappings: [],
})); }));
}); });
}); });
it("validates selected project path is required", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Node With Project" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Path is required")).toBeInTheDocument();
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
it("validates selected project path is absolute", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Node With Project" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
target: { value: "relative/path" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
expect(await screen.findByText("Path must be absolute")).toBeInTheDocument();
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
it("submits selected project mappings", async () => {
render(<AddNodeModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("Build Machine"), {
target: { value: "Node With Project" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "Project One" }));
fireEvent.change(screen.getByDisplayValue("/workspace/project-one"), {
target: { value: "/mnt/node/project-one" },
});
fireEvent.click(screen.getByRole("button", { name: "Add Node" }));
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: "Node With Project",
projectMappings: [{ projectId: "proj-1", path: "/mnt/node/project-one" }],
}));
});
});
it("removes path input when project is deselected", () => {
render(<AddNodeModal {...defaultProps} />);
const checkbox = screen.getByRole("checkbox", { name: "Project One" });
fireEvent.click(checkbox);
expect(screen.getByDisplayValue("/workspace/project-one")).toBeInTheDocument();
fireEvent.click(checkbox);
expect(screen.queryByDisplayValue("/workspace/project-one")).not.toBeInTheDocument();
});
}); });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NodesView } from "../NodesView"; import { NodesView } from "../NodesView";
import type { NodeInfo, ProjectInfo } from "../../api"; import type { NodeInfo, ProjectInfo } from "../../api";
import { useNodes } from "../../hooks/useNodes"; import { useNodes } from "../../hooks/useNodes";
@@ -217,6 +217,35 @@ describe("NodesView", () => {
expect(screen.getByRole("dialog", { name: "Add Node" })).toBeDefined(); expect(screen.getByRole("dialog", { name: "Add Node" })).toBeDefined();
}); });
it("refreshes projects after node registration succeeds", async () => {
const register = vi.fn().mockResolvedValue(makeNode({ id: "node-new", name: "New Node" }));
const refreshProjects = vi.fn().mockResolvedValue(undefined);
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [], register }));
mockUseProjects.mockReturnValue({
projects: [makeProject()],
loading: false,
error: null,
refresh: refreshProjects,
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
});
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
fireEvent.click(screen.getByText("Add Node"));
fireEvent.change(screen.getByPlaceholderText("Build Machine"), { target: { value: "New Node" } });
fireEvent.click(screen.getByTestId("add-node-submit"));
await waitFor(() => {
expect(register).toHaveBeenCalledWith(expect.objectContaining({
name: "New Node",
projectMappings: [],
}));
expect(refreshProjects).toHaveBeenCalledTimes(1);
});
});
it("opens Node Detail modal when a node card is clicked", () => { it("opens Node Detail modal when a node card is clicked", () => {
mockUseProjects.mockReturnValue({ mockUseProjects.mockReturnValue({
projects: [makeProject({ nodeId: "node-1" })], projects: [makeProject({ nodeId: "node-1" })],

View File

@@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react"; import { renderHook, act } from "@testing-library/react";
import { useNodes } from "../useNodes"; import { useNodes } from "../useNodes";
import * as api from "../../api"; import * as api from "../../api";
import type { NodeInfo, NodeCreateInput } from "../../api"; import * as nodeApi from "../../api-node";
import type { NodeInfo, NodeOnboardingInput } from "../../api";
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchNodes: vi.fn(), fetchNodes: vi.fn(),
@@ -12,11 +13,16 @@ vi.mock("../../api", () => ({
checkNodeHealth: vi.fn(), checkNodeHealth: vi.fn(),
})); }));
vi.mock("../../api-node", () => ({
persistNodeProjectPathMappings: vi.fn(),
}));
const mockFetchNodes = vi.mocked(api.fetchNodes); const mockFetchNodes = vi.mocked(api.fetchNodes);
const mockRegisterNode = vi.mocked(api.registerNode); const mockRegisterNode = vi.mocked(api.registerNode);
const mockUpdateNode = vi.mocked(api.updateNode); const mockUpdateNode = vi.mocked(api.updateNode);
const mockUnregisterNode = vi.mocked(api.unregisterNode); const mockUnregisterNode = vi.mocked(api.unregisterNode);
const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth); const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth);
const mockPersistNodeProjectPathMappings = vi.mocked(nodeApi.persistNodeProjectPathMappings);
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo { function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return { return {
@@ -45,6 +51,7 @@ describe("useNodes", () => {
mockUpdateNode.mockReset(); mockUpdateNode.mockReset();
mockUnregisterNode.mockReset(); mockUnregisterNode.mockReset();
mockCheckNodeHealth.mockReset(); mockCheckNodeHealth.mockReset();
mockPersistNodeProjectPathMappings.mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -78,9 +85,14 @@ describe("useNodes", () => {
expect(result.current.error).toBe("boom"); expect(result.current.error).toBe("boom");
}); });
it("register adds node optimistically", async () => { it("register creates node and persists selected path mappings", async () => {
mockFetchNodes.mockResolvedValueOnce([]); mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const nodeInput: NodeCreateInput = { name: "Remote Node", type: "remote", url: "https://node.test" }; const nodeInput: NodeOnboardingInput = {
name: "Remote Node",
type: "remote",
url: "https://node.test",
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
};
const createdNode = makeNode({ const createdNode = makeNode({
id: "node_remote", id: "node_remote",
name: "Remote Node", name: "Remote Node",
@@ -89,6 +101,7 @@ describe("useNodes", () => {
status: "connecting", status: "connecting",
}); });
mockRegisterNode.mockResolvedValueOnce(createdNode); mockRegisterNode.mockResolvedValueOnce(createdNode);
mockPersistNodeProjectPathMappings.mockResolvedValueOnce([]);
const { result } = renderHook(() => useNodes()); const { result } = renderHook(() => useNodes());
@@ -100,9 +113,84 @@ describe("useNodes", () => {
await result.current.register(nodeInput); await result.current.register(nodeInput);
}); });
expect(mockRegisterNode).toHaveBeenCalledWith(nodeInput); expect(mockRegisterNode).toHaveBeenCalledWith({
expect(result.current.nodes).toHaveLength(1); name: "Remote Node",
expect(result.current.nodes[0].id).toBe("node_remote"); type: "remote",
url: "https://node.test",
});
expect(mockPersistNodeProjectPathMappings).toHaveBeenCalledWith("node_remote", nodeInput.projectMappings);
expect(mockFetchNodes).toHaveBeenCalledTimes(2);
});
it("register rolls back node when mapping write fails", async () => {
mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
mockRegisterNode.mockResolvedValueOnce(createdNode);
mockPersistNodeProjectPathMappings.mockRejectedValueOnce(new Error("mapping failed"));
mockUnregisterNode.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useNodes());
await act(async () => {
await flushPromises();
});
await expect(result.current.register({
name: "Remote Node",
type: "remote",
url: "https://node.test",
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
})).rejects.toThrow("mapping failed");
expect(mockUnregisterNode).toHaveBeenCalledWith("node_remote");
expect(mockFetchNodes).toHaveBeenCalledTimes(2);
});
it("register appends cleanup failure message when rollback also fails", async () => {
mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
mockRegisterNode.mockResolvedValueOnce(createdNode);
mockPersistNodeProjectPathMappings.mockRejectedValueOnce(new Error("mapping failed"));
mockUnregisterNode.mockRejectedValueOnce(new Error("cleanup failed"));
const { result } = renderHook(() => useNodes());
await act(async () => {
await flushPromises();
});
await expect(result.current.register({
name: "Remote Node",
type: "remote",
url: "https://node.test",
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
})).rejects.toThrow("mapping failed. Cleanup also failed: cleanup failed");
expect(mockUnregisterNode).toHaveBeenCalledWith("node_remote");
});
it("does not rollback when no mappings are selected even if post-success refresh fails", async () => {
mockFetchNodes.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error("refresh failed"));
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
mockRegisterNode.mockResolvedValueOnce(createdNode);
const { result } = renderHook(() => useNodes());
await act(async () => {
await flushPromises();
});
await act(async () => {
await expect(result.current.register({
name: "Remote Node",
type: "remote",
url: "https://node.test",
projectMappings: [],
})).resolves.toEqual(createdNode);
});
expect(mockPersistNodeProjectPathMappings).not.toHaveBeenCalled();
expect(mockUnregisterNode).not.toHaveBeenCalled();
}); });
it("update modifies node optimistically", async () => { it("update modifies node optimistically", async () => {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api"; import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeOnboardingInput, NodeUpdateInput } from "../api";
import { import {
fetchDockerConfigDiff, fetchDockerConfigDiff,
fetchDockerNodeConfig, fetchDockerNodeConfig,
@@ -10,13 +10,14 @@ import {
unregisterNode, unregisterNode,
checkNodeHealth, checkNodeHealth,
} from "../api"; } from "../api";
import { persistNodeProjectPathMappings } from "../api-node";
export interface UseNodesResult { export interface UseNodesResult {
nodes: NodeInfo[]; nodes: NodeInfo[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
register: (input: NodeCreateInput) => Promise<NodeInfo>; register: (input: NodeOnboardingInput) => Promise<NodeInfo>;
update: (id: string, updates: NodeUpdateInput) => Promise<NodeInfo>; update: (id: string, updates: NodeUpdateInput) => Promise<NodeInfo>;
unregister: (id: string) => Promise<void>; unregister: (id: string) => Promise<void>;
healthCheck: (id: string) => Promise<void>; healthCheck: (id: string) => Promise<void>;
@@ -112,11 +113,36 @@ export function useNodes(): UseNodesResult {
}; };
}, [loading, refresh]); }, [loading, refresh]);
const register = useCallback(async (input: NodeCreateInput): Promise<NodeInfo> => { const register = useCallback(async (input: NodeOnboardingInput): Promise<NodeInfo> => {
const node = await registerNode(input); const { projectMappings, ...nodeInput } = input;
setNodes((prev) => [...prev, node]); const node = await registerNode(nodeInput as NodeCreateInput);
if (projectMappings.length > 0) {
try {
await persistNodeProjectPathMappings(node.id, projectMappings);
} catch (error) {
const mappingError = error instanceof Error ? error.message : "Failed to persist project mappings";
let cleanupErrorMessage = "";
try {
await unregisterNode(node.id);
} catch (cleanupError) {
cleanupErrorMessage = cleanupError instanceof Error
? cleanupError.message
: "Failed to unregister node after mapping failure";
}
await refresh();
if (cleanupErrorMessage) {
throw new Error(`${mappingError}. Cleanup also failed: ${cleanupErrorMessage}`);
}
throw new Error(mappingError);
}
}
await refresh();
return node; return node;
}, []); }, [refresh]);
const update = useCallback(async (id: string, updates: NodeUpdateInput): Promise<NodeInfo> => { const update = useCallback(async (id: string, updates: NodeUpdateInput): Promise<NodeInfo> => {
const node = await updateNode(id, updates); const node = await updateNode(id, updates);