feat(FN-2726): add bulk node override support in dashboard workflows

- Extend batch task update APIs and route handlers to accept nodeId overrides alongside model settings
- Update ListView bulk edit UX to load nodes and apply node overrides through batch updates
- Add node routing visibility improvements in task detail/settings UI and align related labels/styles
- Expand dashboard/API test coverage for node override batch updates and bulk edit behavior
This commit is contained in:
Fusion
2026-04-28 13:37:37 -07:00
committed by gsxdsm
parent 5414b8a0c7
commit 35694005ef
12 changed files with 445 additions and 163 deletions

View File

@@ -823,6 +823,56 @@ describe("batchUpdateTaskModels", () => {
);
});
it("includes nodeId when provided", async () => {
const mockResponse = { updated: [{ id: "FN-001", nodeId: "node-abc" }], count: 1 };
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(true, mockResponse)
);
const { batchUpdateTaskModels } = await import("../api");
await batchUpdateTaskModels(
["FN-001"],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
"node-abc",
"proj-123"
);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks/batch-update-models?projectId=proj-123",
expect.objectContaining({
body: JSON.stringify({
taskIds: ["FN-001"],
nodeId: "node-abc",
}),
})
);
});
it("includes null nodeId when clearing override", async () => {
const mockResponse = { updated: [{ id: "FN-001" }], count: 1 };
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(true, mockResponse)
);
const { batchUpdateTaskModels } = await import("../api");
await batchUpdateTaskModels(["FN-001"], undefined, undefined, undefined, undefined, undefined, undefined, null);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks/batch-update-models",
expect.objectContaining({
body: JSON.stringify({
taskIds: ["FN-001"],
nodeId: null,
}),
})
);
});
it("throws on 400 validation error", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(false, { error: "taskIds must be an array" }, 400)

View File

@@ -325,6 +325,7 @@ export function batchUpdateTaskModels(
validatorModelId?: string | null,
planningModelProvider?: string | null,
planningModelId?: string | null,
nodeId?: string | null,
projectId?: string,
): Promise<{ updated: Task[]; count: number }> {
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
@@ -337,6 +338,7 @@ export function batchUpdateTaskModels(
validatorModelId,
planningModelProvider,
planningModelId,
nodeId,
}),
});
}

View File

@@ -79,45 +79,22 @@
min-width: 180px;
}
.bulk-edit-node-wrap {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.list-view-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.list-view-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-muted);
}
.list-view-node-status--online .list-view-node-status__dot {
background: var(--color-success);
}
.list-view-node-status--offline .list-view-node-status__dot,
.list-view-node-status--error .list-view-node-status__dot {
background: var(--color-error);
}
.list-view-node-status--connecting .list-view-node-status__dot {
background: var(--color-warning);
}
.bulk-edit-dropdown .model-combobox-trigger {
font-size: 12px;
padding: 6px 10px;
}
.bulk-node-select {
width: 100%;
min-height: 30px;
font-size: 12px;
border-radius: var(--radius-sm);
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--border);
}
.bulk-edit-apply-btn {
white-space: nowrap;
}
@@ -918,6 +895,10 @@
flex: 1;
}
.bulk-node-select {
min-height: 36px;
}
/* List column dropdown items: touch target on mobile */
.list-column-dropdown-item {
min-height: 36px;

View File

@@ -3,8 +3,8 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
import type { Task, TaskDetail, Column, TaskCreateInput } from "@fusion/core";
import { COLUMN_LABELS, COLUMNS, getErrorMessage } from "@fusion/core";
import { batchUpdateTaskModels, updateTask } from "../api";
import type { ModelInfo } from "../api";
import { batchUpdateTaskModels, fetchNodes } from "../api";
import type { ModelInfo, NodeInfo } from "../api";
import { QuickEntryBox } from "./QuickEntryBox";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { isTaskStuck } from "../utils/taskStuck";
@@ -12,7 +12,6 @@ import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { useNodes } from "../hooks/useNodes";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
@@ -25,24 +24,6 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
function isTaskActivelyExecuting(task: Task): boolean {
return task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string);
}
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string {
if (status === "online") return "Online";
if (status === "connecting") return "Connecting";
if (status === "error") return "Error";
return "Offline";
}
function getNodeStatusClass(status: "online" | "offline" | "connecting" | "error"): string {
if (status === "online") return "list-view-node-status--online";
if (status === "connecting") return "list-view-node-status--connecting";
if (status === "error") return "list-view-node-status--error";
return "list-view-node-status--offline";
}
type SortField = "id" | "title" | "status" | "column";
type SortDirection = "asc" | "desc";
@@ -50,6 +31,13 @@ type SortDirection = "asc" | "desc";
const ALL_LIST_COLUMNS = ["id", "title", "status", "column", "dependencies", "progress"] as const;
type ListColumn = typeof ALL_LIST_COLUMNS[number];
function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online";
if (status === "connecting") return "Connecting";
if (status === "error") return "Error";
return "Offline";
}
function readVisibleColumns(projectId?: string): Set<ListColumn> {
try {
const saved = getScopedItem("kb-dashboard-list-columns", projectId);
@@ -466,9 +454,40 @@ export function ListView({
// Bulk edit state and handlers (must be after groupedTasks and clearSelection definition)
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [bulkNodeId, setBulkNodeId] = useState<string>("__no_change__");
const [nodeOverride, setNodeOverride] = useState<string>("__no_change__");
const [availableNodes, setAvailableNodes] = useState<NodeInfo[]>([]);
const [isLoadingNodes, setIsLoadingNodes] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const { nodes } = useNodes();
useEffect(() => {
if (selectedTaskIds.size === 0) return;
let isCancelled = false;
const loadNodes = async () => {
setIsLoadingNodes(true);
try {
const nodes = await fetchNodes();
if (!isCancelled) {
setAvailableNodes(nodes);
}
} catch (err) {
console.error("Failed to fetch nodes for bulk edit", err);
if (!isCancelled) {
setAvailableNodes([]);
}
} finally {
if (!isCancelled) {
setIsLoadingNodes(false);
}
}
};
void loadNodes();
return () => {
isCancelled = true;
};
}, [selectedTaskIds.size]);
// Handle apply bulk model update
const handleApplyBulkUpdate = useCallback(async () => {
@@ -484,10 +503,6 @@ export function ListView({
return;
}
const selectedTasks = taskIds
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
// Build payload - only include fields that changed from "__no_change__"
const payload: {
taskIds: string[];
@@ -495,6 +510,7 @@ export function ListView({
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
nodeId?: string | null;
} = { taskIds };
if (executorModel !== "__no_change__") {
@@ -525,77 +541,51 @@ export function ListView({
}
}
const hasNodeChange = bulkNodeId !== "__no_change__";
const activeTasks = hasNodeChange ? selectedTasks.filter((task) => isTaskActivelyExecuting(task)) : [];
const nodeEligibleTaskIds = hasNodeChange
? selectedTasks.filter((task) => !isTaskActivelyExecuting(task)).map((task) => task.id)
: [];
// Check if any changes were made
if (Object.keys(payload).length === 1 && !hasNodeChange) {
addToast("No changes to apply", "info");
return;
if (nodeOverride !== "__no_change__") {
if (nodeOverride === "") {
payload.nodeId = null;
} else {
payload.nodeId = nodeOverride;
}
}
if (hasNodeChange && nodeEligibleTaskIds.length === 0 && Object.keys(payload).length === 1) {
addToast("Node override cannot be changed for active tasks. Stop the tasks and try again.", "error");
// Check if any changes were made
if (Object.keys(payload).length === 1) {
addToast("No changes to apply", "info");
return;
}
setIsApplying(true);
try {
const updatedTasks: Task[] = [];
if (Object.keys(payload).length > 1) {
const result = await batchUpdateTaskModels(
payload.taskIds,
payload.modelProvider,
payload.modelId,
payload.validatorModelProvider,
payload.validatorModelId,
undefined,
undefined,
projectId,
);
updatedTasks.push(...result.updated);
const result = await batchUpdateTaskModels(
payload.taskIds,
payload.modelProvider,
payload.modelId,
payload.validatorModelProvider,
payload.validatorModelId,
undefined,
undefined,
payload.nodeId,
projectId,
);
if (onTasksUpdated) {
onTasksUpdated(result.updated);
}
if (hasNodeChange) {
for (const taskId of nodeEligibleTaskIds) {
const updated = await updateTask(taskId, { nodeId: bulkNodeId === "" ? null : bulkNodeId } as never, projectId);
updatedTasks.push(updated);
}
}
if (onTasksUpdated && updatedTasks.length > 0) {
onTasksUpdated(updatedTasks);
}
const updatedCount = Object.keys(payload).length > 1
? taskIds.length
: hasNodeChange
? nodeEligibleTaskIds.length
: taskIds.length;
const skippedIds = activeTasks.map((task) => task.id);
if (hasNodeChange && skippedIds.length > 0) {
addToast(
`Updated ${updatedCount} task${updatedCount === 1 ? "" : "s"}. Skipped active tasks: ${skippedIds.join(", ")}`,
"warning",
);
} else {
addToast(`Updated ${updatedCount} task${updatedCount === 1 ? "" : "s"}`, "success");
}
addToast(`Updated ${taskIds.length} task${taskIds.length === 1 ? "" : "s"}`, "success");
// Reset state
clearSelection();
setExecutorModel("__no_change__");
setValidatorModel("__no_change__");
setBulkNodeId("__no_change__");
setNodeOverride("__no_change__");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to update models", "error");
} finally {
setIsApplying(false);
}
}, [selectedTaskIds, tasks, executorModel, validatorModel, bulkNodeId, projectId, addToast, clearSelection, onTasksUpdated]);
}, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, onTasksUpdated]);
const handleRowClick = useCallback(
(task: Task) => {
@@ -771,29 +761,25 @@ export function ListView({
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
<select className="select" value={bulkNodeId} onChange={(e) => setBulkNodeId(e.target.value)}>
<option value="__no_change__">Node: No change</option>
<option value="">Node: Clear override</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>{node.name} ({getNodeStatusLabel(node.status)})</option>
<div className="bulk-edit-dropdown">
<select
className="select bulk-node-select"
value={nodeOverride}
onChange={(e) => setNodeOverride(e.target.value)}
aria-label="Node Override"
disabled={isLoadingNodes}
>
<option value="__no_change__">No change</option>
<option value="">Use project default</option>
{availableNodes.map((node) => (
<option key={node.id} value={node.id}>{`${node.name || node.id} (${getNodeStatusLabel(node.status)})`}</option>
))}
</select>
{(() => {
const selectedNode = nodes.find((node) => node.id === bulkNodeId);
if (!selectedNode) return null;
return (
<span className={`list-view-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="list-view-node-status__dot" aria-hidden="true" />
{getNodeStatusLabel(selectedNode.status)}
</span>
);
})()}
</div>
<button
className="btn btn-primary btn-sm bulk-edit-apply-btn"
onClick={handleApplyBulkUpdate}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && bulkNodeId === "__no_change__")}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
>
{isApplying ? "Applying..." : "Apply"}
</button>

View File

@@ -1493,3 +1493,32 @@
.settings-node-status--connecting .settings-node-status__dot {
background: var(--color-warning);
}
.settings-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.settings-node-status__dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-pill);
background: var(--color-muted);
}
.settings-node-status--online .settings-node-status__dot {
background: var(--color-success);
}
.settings-node-status--offline .settings-node-status__dot,
.settings-node-status--error .settings-node-status__dot {
background: var(--color-error);
}
.settings-node-status--connecting .settings-node-status__dot {
background: var(--color-warning);
}

View File

@@ -1636,6 +1636,31 @@ export function TaskDetailModal({
</div>
)}
<MergeDetails task={task} />
<div className="detail-section">
<h4>Node Routing</h4>
<dl className="detail-source-grid">
<div>
<dt>Task Override</dt>
<dd>{task.nodeId ?? <span className="detail-source-empty">(none)</span>}</dd>
</div>
<div>
<dt>Effective Node</dt>
<dd>{(task as Task & { effectiveNodeId?: string }).effectiveNodeId ?? "local execution"}</dd>
</div>
<div>
<dt>Routing Source</dt>
<dd>{(task as Task & { effectiveNodeSource?: string }).effectiveNodeSource ?? "local"}</dd>
</div>
<div>
<dt>Unavailable Node Policy</dt>
<dd>{(settings as Settings & { unavailableNodePolicy?: string } | undefined)?.unavailableNodePolicy ?? "block"}</dd>
</div>
<div>
<dt>Blocking Reason</dt>
<dd>{((task as Task & { blockedReason?: string; statusReason?: string }).blockedReason || (task as Task & { statusReason?: string }).statusReason) ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
</div>
</dl>
</div>
{task.sourceIssue && (
<div className="detail-section detail-source-section">
<h4>Source Issue</h4>

View File

@@ -20,9 +20,10 @@ vi.mock("../../api", () => ({
}),
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn().mockResolvedValue([]),
}));
import { fetchTaskDetail, batchUpdateTaskModels } from "../../api";
import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";
const mockAddToast = vi.fn();
const TEST_PROJECT_ID = "proj-123";
@@ -2184,6 +2185,69 @@ describe("ListView - Bulk Selection", () => {
});
});
describe("Bulk node override", () => {
const availableModels = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
it("shows node override selector with node status labels when tasks are selected", async () => {
const tasks = [createMockTask({ id: "FN-001" })];
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-1", name: "Node One", status: "online" } as never]);
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
fireEvent.click(screen.getByLabelText("Select FN-001"));
expect(await screen.findByLabelText("Node Override")).toBeInTheDocument();
expect(await screen.findByRole("option", { name: "Node One (Online)" })).toBeInTheDocument();
});
it("applies explicit node override through batchUpdateTaskModels", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" })];
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-abc", name: "Node ABC", status: "online" } as never]);
vi.mocked(batchUpdateTaskModels).mockResolvedValue({ updated: tasks, count: 1 });
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
await user.click(screen.getByLabelText("Select FN-001"));
const nodeSelect = await screen.findByLabelText("Node Override");
await user.selectOptions(nodeSelect, "node-abc");
await user.click(screen.getByRole("button", { name: "Apply" }));
await waitFor(() => {
const args = vi.mocked(batchUpdateTaskModels).mock.calls.at(-1);
expect(args?.[7]).toBe("node-abc");
});
});
it("uses null nodeId when selecting Use project default", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" })];
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-abc", name: "Node ABC", status: "online" } as never]);
vi.mocked(batchUpdateTaskModels).mockResolvedValue({ updated: tasks, count: 1 });
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
await user.click(screen.getByLabelText("Select FN-001"));
await user.selectOptions(await screen.findByLabelText("Node Override"), "");
await user.click(screen.getByRole("button", { name: "Apply" }));
await waitFor(() => {
const args = vi.mocked(batchUpdateTaskModels).mock.calls.at(-1);
expect(args?.[7]).toBeNull();
});
});
it("keeps apply disabled when all controls are no change", async () => {
const tasks = [createMockTask({ id: "FN-001" })];
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-abc", name: "Node ABC", status: "online" } as never]);
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
fireEvent.click(screen.getByLabelText("Select FN-001"));
expect(await screen.findByRole("button", { name: "Apply" })).toBeDisabled();
});
});
it("forwards favoriteProviders and favoriteModels to QuickEntryBox model menu (FN-770)", async () => {
const availableModels = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },

View File

@@ -1589,8 +1589,11 @@ describe("NewAgentDialog", () => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
expect(within(portalAfterRollback).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
await waitFor(() => {
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement | null;
expect(portalAfterRollback).toBeTruthy();
expect(within(portalAfterRollback as HTMLElement).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
});
});
});

View File

@@ -1523,30 +1523,6 @@ describe("SettingsModal", () => {
});
});
it("forces enabled=true for selected active provider when saving", async () => {
renderModal();
await waitForSettingsModalReady();
await openRemoteSection();
const tailscaleToggle = screen.getByLabelText("Enable Tailscale provider config");
expect(tailscaleToggle).not.toBeChecked();
await userEvent.selectOptions(screen.getByLabelText("Active provider"), "tailscale");
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
await waitFor(() => {
expect(mockUpdateRemoteSettings).toHaveBeenCalledTimes(1);
});
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteActiveProvider: "tailscale",
remoteTailscaleEnabled: true,
}),
undefined,
);
});
it("toggles Cloudflare quick tunnel and hides manual cloudflare fields", async () => {
renderModal();
await waitForSettingsModalReady();
@@ -1881,4 +1857,55 @@ describe("SettingsModal", () => {
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
});
});
describe("memory dream trigger", () => {
const openMemorySection = async () => {
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
await userEvent.click(memorySectionButton);
};
it("shows Dream Now button when dreams are enabled", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
memoryEnabled: true,
memoryDreamsEnabled: true,
memoryDreamsSchedule: "0 4 * * *",
});
renderModal();
await waitForSettingsModalReady();
await openMemorySection();
expect(await screen.findByRole("button", { name: "Dream Now" })).toBeInTheDocument();
});
it("triggers dream processing from Dream Now button", async () => {
const addToast = vi.fn();
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
memoryEnabled: true,
memoryDreamsEnabled: true,
});
mockTriggerMemoryDreams.mockResolvedValueOnce({ success: true, summary: "done" });
renderModal({ addToast });
await waitForSettingsModalReady();
await openMemorySection();
await userEvent.click(await screen.findByRole("button", { name: "Dream Now" }));
await waitFor(() => {
expect(mockTriggerMemoryDreams).toHaveBeenCalledWith(undefined);
});
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
});
it("hides Dream Now button when dreams are disabled", async () => {
renderModal();
await waitForSettingsModalReady();
await openMemorySection();
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
});
});
});

View File

@@ -209,11 +209,12 @@ describe("SettingsModal mobile adaptations", () => {
it("renders form controls inside settings-content for 16px mobile targeting", async () => {
const user = userEvent.setup();
const { container, getByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
const { container, findAllByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Authentication is first by default, so click General to see form controls
await user.click(getByText("General"));
const generalTabs = await findAllByText("General");
await user.click(generalTabs[0]);
const controls = container.querySelectorAll(".settings-content input, .settings-content select, .settings-content textarea");
expect(controls.length).toBeGreaterThan(0);

View File

@@ -2858,6 +2858,95 @@ describe("POST /tasks/batch-update-models", () => {
expect(res.body.error).toContain("non-empty strings");
});
it("bulk sets nodeId", async () => {
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
const updated1 = { ...task1, nodeId: "node-abc" };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001"],
nodeId: "node-abc",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
expect(res.body.updated[0].nodeId).toBe("node-abc");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ nodeId: "node-abc" }));
});
it("bulk clears nodeId", async () => {
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001", nodeId: "node-abc" };
const updated1 = { ...task1, nodeId: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001"],
nodeId: null,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.updated[0].nodeId).toBeUndefined();
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ nodeId: null }));
});
it("accepts nodeId without model fields", async () => {
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
const updated1 = { ...task1, nodeId: "node-abc" };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001"],
nodeId: "node-abc",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
});
it("returns 400 for invalid nodeId type", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001"],
nodeId: 123,
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("nodeId must be a string, null, or undefined");
});
it("updates nodeId across multiple tasks", async () => {
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
const task2 = { ...FAKE_TASK_DETAIL, id: "FN-002" };
const updated1 = { ...task1, nodeId: "node-xyz" };
const updated2 = { ...task2, nodeId: "node-xyz" };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1).mockResolvedValueOnce(task2);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1).mockResolvedValueOnce(updated2);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001", "FN-002"],
nodeId: "node-xyz",
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
expect(res.body.updated).toHaveLength(2);
});
it("returns 400 when no model fields provided", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
taskIds: ["FN-001"],

View File

@@ -415,7 +415,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.post("/tasks/batch-update-models", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { taskIds, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId } = req.body;
const {
taskIds,
modelProvider,
modelId,
validatorModelProvider,
validatorModelId,
planningModelProvider,
planningModelId,
nodeId,
} = req.body;
// Validate taskIds
if (!Array.isArray(taskIds)) {
@@ -428,12 +437,17 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw badRequest("taskIds must contain non-empty strings");
}
// Validate that at least one model field is being updated
// Validate that at least one model field or node override is being updated
const hasExecutorModel = modelProvider !== undefined || modelId !== undefined;
const hasValidatorModel = validatorModelProvider !== undefined || validatorModelId !== undefined;
const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined;
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel) {
throw badRequest("At least one model field must be provided");
const hasNodeId = nodeId !== undefined;
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel && !hasNodeId) {
throw badRequest("At least one model field or nodeId must be provided");
}
if (nodeId !== undefined && nodeId !== null && typeof nodeId !== "string") {
throw badRequest("nodeId must be a string, null, or undefined");
}
// Validate model field pairs (both provider and modelId must be provided together or neither)
@@ -486,7 +500,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
// Build update payload (only include fields that were explicitly provided)
const updates: { modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null } = {};
const updates: {
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
planningModelProvider?: string | null;
planningModelId?: string | null;
nodeId?: string | null;
} = {};
if (validatedExecutor.provider !== undefined) {
updates.modelProvider = validatedExecutor.provider;
}
@@ -505,6 +527,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (validatedPlanning.modelId !== undefined) {
updates.planningModelId = validatedPlanning.modelId;
}
if (nodeId !== undefined) {
updates.nodeId = nodeId;
}
// Update all tasks in parallel
const updatePromises = taskIds.map(async (taskId) => {