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:
@@ -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 () => {
|
it("throws on 400 validation error", async () => {
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
mockFetchResponse(false, { error: "taskIds must be an array" }, 400)
|
mockFetchResponse(false, { error: "taskIds must be an array" }, 400)
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ export function batchUpdateTaskModels(
|
|||||||
validatorModelId?: string | null,
|
validatorModelId?: string | null,
|
||||||
planningModelProvider?: string | null,
|
planningModelProvider?: string | null,
|
||||||
planningModelId?: string | null,
|
planningModelId?: string | null,
|
||||||
|
nodeId?: string | null,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
): Promise<{ updated: Task[]; count: number }> {
|
): Promise<{ updated: Task[]; count: number }> {
|
||||||
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
|
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
|
||||||
@@ -337,6 +338,7 @@ export function batchUpdateTaskModels(
|
|||||||
validatorModelId,
|
validatorModelId,
|
||||||
planningModelProvider,
|
planningModelProvider,
|
||||||
planningModelId,
|
planningModelId,
|
||||||
|
nodeId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,45 +79,22 @@
|
|||||||
min-width: 180px;
|
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 {
|
.bulk-edit-dropdown .model-combobox-trigger {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 6px 10px;
|
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 {
|
.bulk-edit-apply-btn {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -918,6 +895,10 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bulk-node-select {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
/* List column dropdown items: touch target on mobile */
|
/* List column dropdown items: touch target on mobile */
|
||||||
.list-column-dropdown-item {
|
.list-column-dropdown-item {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
|
|||||||
@@ -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 { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
|
||||||
import type { Task, TaskDetail, Column, TaskCreateInput } from "@fusion/core";
|
import type { Task, TaskDetail, Column, TaskCreateInput } from "@fusion/core";
|
||||||
import { COLUMN_LABELS, COLUMNS, getErrorMessage } from "@fusion/core";
|
import { COLUMN_LABELS, COLUMNS, getErrorMessage } from "@fusion/core";
|
||||||
import { batchUpdateTaskModels, updateTask } from "../api";
|
import { batchUpdateTaskModels, fetchNodes } from "../api";
|
||||||
import type { ModelInfo } from "../api";
|
import type { ModelInfo, NodeInfo } from "../api";
|
||||||
import { QuickEntryBox } from "./QuickEntryBox";
|
import { QuickEntryBox } from "./QuickEntryBox";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { isTaskStuck } from "../utils/taskStuck";
|
import { isTaskStuck } from "../utils/taskStuck";
|
||||||
@@ -12,7 +12,6 @@ import type { ToastType } from "../hooks/useToast";
|
|||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||||
import { useNodes } from "../hooks/useNodes";
|
|
||||||
|
|
||||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||||
triage: "var(--triage)",
|
triage: "var(--triage)",
|
||||||
@@ -25,24 +24,6 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
|||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
|
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 SortField = "id" | "title" | "status" | "column";
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
@@ -50,6 +31,13 @@ type SortDirection = "asc" | "desc";
|
|||||||
const ALL_LIST_COLUMNS = ["id", "title", "status", "column", "dependencies", "progress"] as const;
|
const ALL_LIST_COLUMNS = ["id", "title", "status", "column", "dependencies", "progress"] as const;
|
||||||
type ListColumn = typeof ALL_LIST_COLUMNS[number];
|
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> {
|
function readVisibleColumns(projectId?: string): Set<ListColumn> {
|
||||||
try {
|
try {
|
||||||
const saved = getScopedItem("kb-dashboard-list-columns", projectId);
|
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)
|
// Bulk edit state and handlers (must be after groupedTasks and clearSelection definition)
|
||||||
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
|
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
|
||||||
const [validatorModel, setValidatorModel] = 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 [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
|
// Handle apply bulk model update
|
||||||
const handleApplyBulkUpdate = useCallback(async () => {
|
const handleApplyBulkUpdate = useCallback(async () => {
|
||||||
@@ -484,10 +503,6 @@ export function ListView({
|
|||||||
return;
|
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__"
|
// Build payload - only include fields that changed from "__no_change__"
|
||||||
const payload: {
|
const payload: {
|
||||||
taskIds: string[];
|
taskIds: string[];
|
||||||
@@ -495,6 +510,7 @@ export function ListView({
|
|||||||
modelId?: string | null;
|
modelId?: string | null;
|
||||||
validatorModelProvider?: string | null;
|
validatorModelProvider?: string | null;
|
||||||
validatorModelId?: string | null;
|
validatorModelId?: string | null;
|
||||||
|
nodeId?: string | null;
|
||||||
} = { taskIds };
|
} = { taskIds };
|
||||||
|
|
||||||
if (executorModel !== "__no_change__") {
|
if (executorModel !== "__no_change__") {
|
||||||
@@ -525,77 +541,51 @@ export function ListView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNodeChange = bulkNodeId !== "__no_change__";
|
if (nodeOverride !== "__no_change__") {
|
||||||
const activeTasks = hasNodeChange ? selectedTasks.filter((task) => isTaskActivelyExecuting(task)) : [];
|
if (nodeOverride === "") {
|
||||||
const nodeEligibleTaskIds = hasNodeChange
|
payload.nodeId = null;
|
||||||
? selectedTasks.filter((task) => !isTaskActivelyExecuting(task)).map((task) => task.id)
|
} else {
|
||||||
: [];
|
payload.nodeId = nodeOverride;
|
||||||
|
}
|
||||||
// Check if any changes were made
|
|
||||||
if (Object.keys(payload).length === 1 && !hasNodeChange) {
|
|
||||||
addToast("No changes to apply", "info");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNodeChange && nodeEligibleTaskIds.length === 0 && Object.keys(payload).length === 1) {
|
// Check if any changes were made
|
||||||
addToast("Node override cannot be changed for active tasks. Stop the tasks and try again.", "error");
|
if (Object.keys(payload).length === 1) {
|
||||||
|
addToast("No changes to apply", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsApplying(true);
|
setIsApplying(true);
|
||||||
try {
|
try {
|
||||||
const updatedTasks: Task[] = [];
|
const result = await batchUpdateTaskModels(
|
||||||
if (Object.keys(payload).length > 1) {
|
payload.taskIds,
|
||||||
const result = await batchUpdateTaskModels(
|
payload.modelProvider,
|
||||||
payload.taskIds,
|
payload.modelId,
|
||||||
payload.modelProvider,
|
payload.validatorModelProvider,
|
||||||
payload.modelId,
|
payload.validatorModelId,
|
||||||
payload.validatorModelProvider,
|
undefined,
|
||||||
payload.validatorModelId,
|
undefined,
|
||||||
undefined,
|
payload.nodeId,
|
||||||
undefined,
|
projectId,
|
||||||
projectId,
|
);
|
||||||
);
|
|
||||||
updatedTasks.push(...result.updated);
|
if (onTasksUpdated) {
|
||||||
|
onTasksUpdated(result.updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNodeChange) {
|
addToast(`Updated ${taskIds.length} task${taskIds.length === 1 ? "" : "s"}`, "success");
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
clearSelection();
|
clearSelection();
|
||||||
setExecutorModel("__no_change__");
|
setExecutorModel("__no_change__");
|
||||||
setValidatorModel("__no_change__");
|
setValidatorModel("__no_change__");
|
||||||
setBulkNodeId("__no_change__");
|
setNodeOverride("__no_change__");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(getErrorMessage(err) || "Failed to update models", "error");
|
addToast(getErrorMessage(err) || "Failed to update models", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setIsApplying(false);
|
setIsApplying(false);
|
||||||
}
|
}
|
||||||
}, [selectedTaskIds, tasks, executorModel, validatorModel, bulkNodeId, projectId, addToast, clearSelection, onTasksUpdated]);
|
}, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, onTasksUpdated]);
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(task: Task) => {
|
(task: Task) => {
|
||||||
@@ -771,29 +761,25 @@ export function ListView({
|
|||||||
onToggleModelFavorite={onToggleModelFavorite}
|
onToggleModelFavorite={onToggleModelFavorite}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
|
<div className="bulk-edit-dropdown">
|
||||||
<select className="select" value={bulkNodeId} onChange={(e) => setBulkNodeId(e.target.value)}>
|
<select
|
||||||
<option value="__no_change__">Node: No change</option>
|
className="select bulk-node-select"
|
||||||
<option value="">Node: Clear override</option>
|
value={nodeOverride}
|
||||||
{nodes.map((node) => (
|
onChange={(e) => setNodeOverride(e.target.value)}
|
||||||
<option key={node.id} value={node.id}>{node.name} ({getNodeStatusLabel(node.status)})</option>
|
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>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-sm bulk-edit-apply-btn"
|
className="btn btn-primary btn-sm bulk-edit-apply-btn"
|
||||||
onClick={handleApplyBulkUpdate}
|
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"}
|
{isApplying ? "Applying..." : "Apply"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1493,3 +1493,32 @@
|
|||||||
.settings-node-status--connecting .settings-node-status__dot {
|
.settings-node-status--connecting .settings-node-status__dot {
|
||||||
background: var(--color-warning);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1636,6 +1636,31 @@ export function TaskDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MergeDetails task={task} />
|
<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 && (
|
{task.sourceIssue && (
|
||||||
<div className="detail-section detail-source-section">
|
<div className="detail-section detail-source-section">
|
||||||
<h4>Source Issue</h4>
|
<h4>Source Issue</h4>
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ vi.mock("../../api", () => ({
|
|||||||
}),
|
}),
|
||||||
fetchTaskDetail: vi.fn(),
|
fetchTaskDetail: vi.fn(),
|
||||||
batchUpdateTaskModels: 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 mockAddToast = vi.fn();
|
||||||
const TEST_PROJECT_ID = "proj-123";
|
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 () => {
|
it("forwards favoriteProviders and favoriteModels to QuickEntryBox model menu (FN-770)", async () => {
|
||||||
const availableModels = [
|
const availableModels = [
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||||
|
|||||||
@@ -1589,8 +1589,11 @@ describe("NewAgentDialog", () => {
|
|||||||
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
|
await waitFor(() => {
|
||||||
expect(within(portalAfterRollback).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
it("toggles Cloudflare quick tunnel and hides manual cloudflare fields", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
@@ -1881,4 +1857,55 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,11 +209,12 @@ describe("SettingsModal mobile adaptations", () => {
|
|||||||
|
|
||||||
it("renders form controls inside settings-content for 16px mobile targeting", async () => {
|
it("renders form controls inside settings-content for 16px mobile targeting", async () => {
|
||||||
const user = userEvent.setup();
|
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());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
// Authentication is first by default, so click General to see form controls
|
// 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");
|
const controls = container.querySelectorAll(".settings-content input, .settings-content select, .settings-content textarea");
|
||||||
expect(controls.length).toBeGreaterThan(0);
|
expect(controls.length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -2858,6 +2858,95 @@ describe("POST /tasks/batch-update-models", () => {
|
|||||||
expect(res.body.error).toContain("non-empty strings");
|
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 () => {
|
it("returns 400 when no model fields provided", async () => {
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||||
taskIds: ["FN-001"],
|
taskIds: ["FN-001"],
|
||||||
|
|||||||
@@ -415,7 +415,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
router.post("/tasks/batch-update-models", async (req, res) => {
|
router.post("/tasks/batch-update-models", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
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
|
// Validate taskIds
|
||||||
if (!Array.isArray(taskIds)) {
|
if (!Array.isArray(taskIds)) {
|
||||||
@@ -428,12 +437,17 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
throw badRequest("taskIds must contain non-empty strings");
|
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 hasExecutorModel = modelProvider !== undefined || modelId !== undefined;
|
||||||
const hasValidatorModel = validatorModelProvider !== undefined || validatorModelId !== undefined;
|
const hasValidatorModel = validatorModelProvider !== undefined || validatorModelId !== undefined;
|
||||||
const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined;
|
const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined;
|
||||||
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel) {
|
const hasNodeId = nodeId !== undefined;
|
||||||
throw badRequest("At least one model field must be provided");
|
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)
|
// 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)
|
// 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) {
|
if (validatedExecutor.provider !== undefined) {
|
||||||
updates.modelProvider = validatedExecutor.provider;
|
updates.modelProvider = validatedExecutor.provider;
|
||||||
}
|
}
|
||||||
@@ -505,6 +527,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
if (validatedPlanning.modelId !== undefined) {
|
if (validatedPlanning.modelId !== undefined) {
|
||||||
updates.planningModelId = validatedPlanning.modelId;
|
updates.planningModelId = validatedPlanning.modelId;
|
||||||
}
|
}
|
||||||
|
if (nodeId !== undefined) {
|
||||||
|
updates.nodeId = nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
// Update all tasks in parallel
|
// Update all tasks in parallel
|
||||||
const updatePromises = taskIds.map(async (taskId) => {
|
const updatePromises = taskIds.map(async (taskId) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user