feat(FN-2721): enforce node override guard across task workflows

- Add a core node-override-guard module, export it from @fusion/core, and enforce conflicts in store updates
- Add API route and CLI extension safeguards so nodeId override updates are blocked when ownership would conflict
- Wire node override routing and validation through dashboard quick-create, list, modal, settings, and task form/detail surfaces
- Add focused unit and integration tests for core guard logic, workflow routes, and dashboard node override UX
This commit is contained in:
Fusion
2026-04-28 01:49:55 -07:00
committed by gsxdsm
parent 4ee7f6b2b8
commit 02ae48b0d6
22 changed files with 670 additions and 39 deletions

View File

@@ -29,6 +29,7 @@ Update fields on an existing task. Supports modifying the title, description, de
| `description` | string | — | New task description |
| `depends` | array | — | New dependency list — replaces existing dependencies (e.g. ['FN-001', 'FN-002']) |
| `agentId` | union | — | Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123') |
| `nodeId` | union | — | Node ID override for this task, or null to clear |
### fn_task_list

View File

@@ -5,6 +5,7 @@ import {
TaskStore,
COLUMNS,
COLUMN_LABELS,
validateNodeOverrideChange,
type Task,
} from "@fusion/core";
import {
@@ -228,14 +229,20 @@ export default function kbExtension(pi: ExtensionAPI) {
description: "Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123')",
}),
),
nodeId: Type.Optional(
Type.Union([Type.String(), Type.Null()], {
description: "Node ID override for this task, or null to clear",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
// Validate task exists
let task: Task;
try {
await store.getTask(params.id);
task = await store.getTask(params.id);
} catch {
return {
content: [{ type: "text", text: `Task ${params.id} not found` }],
@@ -264,10 +271,22 @@ export default function kbExtension(pi: ExtensionAPI) {
updates.assignedAgentId = params.agentId;
updatedFields.push("agentId");
}
if (params.nodeId !== undefined) {
const validation = validateNodeOverrideChange(task, params.nodeId ?? null);
if (!validation.allowed) {
return {
content: [{ type: "text", text: validation.message ?? "Node override change blocked" }],
isError: true,
details: { error: validation.reason },
};
}
updates.nodeId = params.nodeId;
updatedFields.push("nodeId");
}
if (updatedFields.length === 0) {
return {
content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId." }],
content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId, nodeId." }],
isError: true,
details: { error: "No fields provided" },
};

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { validateNodeOverrideChange } from "../node-override-guard.js";
describe("validateNodeOverrideChange", () => {
it("allows when newNodeId is undefined (not being changed)", () => {
const result = validateNodeOverrideChange(
{ id: "FN-1", column: "in-progress", nodeId: "node-a" },
undefined,
);
expect(result).toEqual({ allowed: true });
});
it.each(["triage", "todo", "in-review", "done", "archived"])(
"allows setting nodeId on a task in %s",
(column) => {
const result = validateNodeOverrideChange({ id: "FN-1", column }, "node-b");
expect(result).toEqual({ allowed: true });
},
);
it("allows clearing nodeId (null) on a task in todo", () => {
const result = validateNodeOverrideChange(
{ id: "FN-1", column: "todo", nodeId: "node-a" },
null,
);
expect(result).toEqual({ allowed: true });
});
it("allows changing nodeId from one value to another in todo", () => {
const result = validateNodeOverrideChange(
{ id: "FN-1", column: "todo", nodeId: "node-a" },
"node-b",
);
expect(result).toEqual({ allowed: true });
});
it.each(["node-a", null, "same-node"])(
"blocks nodeId updates on an in-progress task for value %p",
(newNodeId) => {
const result = validateNodeOverrideChange(
{ id: "FN-999", column: "in-progress", nodeId: "same-node" },
newNodeId,
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("task-in-progress");
expect(result.message).toContain("FN-999");
expect(result.message?.toLowerCase()).toContain("in progress");
expect(result.message).toContain("pause/stop");
},
);
it("allows nodeId change on in-progress task when newNodeId is undefined (no-op)", () => {
const result = validateNodeOverrideChange(
{ id: "FN-2", column: "in-progress", nodeId: "node-a" },
undefined,
);
expect(result).toEqual({ allowed: true });
});
it("blocks setting nodeId to same value on in-progress when passed as explicit string", () => {
const result = validateNodeOverrideChange(
{ id: "FN-2", column: "in-progress", nodeId: "node-a" },
"node-a",
);
expect(result.allowed).toBe(false);
});
});

View File

@@ -511,6 +511,54 @@ describe("TaskStore", () => {
});
});
describe("nodeId in-progress blocking", () => {
it("throws when updating nodeId on an in-progress task", async () => {
const task = await store.createTask({ description: "In progress task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.updateTask(task.id, { nodeId: "node-abc" }))
.rejects.toThrow(/in progress/i);
});
it("allows updating nodeId on a todo task", async () => {
const task = await store.createTask({ description: "Todo task" });
const updated = await store.updateTask(task.id, { nodeId: "node-todo" });
expect(updated.nodeId).toBe("node-todo");
});
it("allows updating nodeId on an in-review task", async () => {
const task = await store.createTask({ description: "Review task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const updated = await store.updateTask(task.id, { nodeId: "node-review" });
expect(updated.nodeId).toBe("node-review");
});
it("allows other updates on in-progress tasks (non-nodeId)", async () => {
const task = await store.createTask({ description: "In progress title update" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
const updated = await store.updateTask(task.id, { title: "Updated title" });
expect(updated.title).toBe("Updated title");
});
it("allows clearing nodeId on a done task", async () => {
const task = await store.createTask({ description: "Done task", nodeId: "node-done" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const updated = await store.updateTask(task.id, { nodeId: null });
expect(updated.nodeId).toBeUndefined();
});
});
describe("selectNextTaskForAgent", () => {
it("returns null when no tasks exist", async () => {
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();

View File

@@ -74,6 +74,11 @@ export { AutomationStore } from "./automation-store.js";
export type { AutomationStoreEvents } from "./automation-store.js";
export { runCommandAsync } from "./run-command.js";
export type { RunCommandOptions, RunCommandResult } from "./run-command.js";
export {
validateNodeOverrideChange,
type NodeOverrideValidationResult,
type NodeOverrideBlockReason,
} from "./node-override-guard.js";
export { validateUnavailableNodePolicy } from "./settings-validation.js";
// ── Routine System ───────────────────────────────────────────────────

View File

@@ -0,0 +1,26 @@
export type NodeOverrideBlockReason = "task-in-progress";
export interface NodeOverrideValidationResult {
allowed: boolean;
reason?: NodeOverrideBlockReason;
message?: string;
}
export function validateNodeOverrideChange(
task: { column: string; nodeId?: string; id: string },
newNodeId: string | null | undefined,
): NodeOverrideValidationResult {
if (newNodeId === undefined) {
return { allowed: true };
}
if (task.column === "in-progress") {
return {
allowed: false,
reason: "task-in-progress",
message: `Cannot change node override for ${task.id} while it is in progress. The task is currently executing and routing cannot be changed mid-flight. Wait for the task to complete, or pause/stop it first before changing the node assignment.`,
};
}
return { allowed: true };
}

View File

@@ -21,6 +21,7 @@ import { getTaskMergeBlocker } from "./task-merge.js";
import { ensureMemoryFileWithBackend } from "./project-memory.js";
import { runCommandAsync } from "./run-command.js";
import { createLogger } from "./logger.js";
import { validateNodeOverrideChange } from "./node-override-guard.js";
/** Database row shape for the tasks table (all columns). */
interface TaskRow {
@@ -2630,6 +2631,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
if (updates.nodeId !== undefined) {
const validation = validateNodeOverrideChange(task, updates.nodeId ?? null);
if (!validation.allowed) {
throw new Error(validation.message);
}
}
// Initialize log array if missing (for legacy tasks)
if (!task.log) {
task.log = [];

View File

@@ -7,7 +7,6 @@ import {
type HermesProfileSummary,
type HermesProviderStatus,
} from "../api";
import { ProviderIcon } from "./ProviderIcon";
import { RuntimeCardShell } from "./RuntimeCardShell";
const PLUGIN_ID = "fusion-plugin-hermes-runtime";

View File

@@ -215,6 +215,15 @@
align-items: center;
}
.inline-create-node-wrap {
display: inline-flex;
align-items: center;
}
.inline-create-node-select {
max-width: 250px;
}
.inline-create-priority-select {
max-width: 180px;
}

View File

@@ -6,7 +6,8 @@ import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskCreateInput
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents } from "../api";
import type { ModelInfo, Agent } from "../api";
import type { ModelInfo, Agent, NodeInfo } from "../api";
import { useNodes } from "../hooks/useNodes";
import { ModelSelectionModal } from "./ModelSelectionModal";
import { applyPresetToSelection } from "../utils/modelPresets";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
@@ -41,6 +42,12 @@ interface InlineCreateCardProps {
onSubtaskBreakdown?: (description: string) => void;
}
function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online";
if (status === "connecting") return "Connecting";
return "Offline";
}
function getModelSelectionValue(provider?: string, modelId?: string): string {
return provider && modelId ? `${provider}/${modelId}` : "";
}
@@ -86,6 +93,8 @@ export function InlineCreateCard({
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const { nodes } = useNodes();
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
@@ -320,6 +329,7 @@ export function InlineCreateCard({
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined,
priority,
nodeId,
});
// Upload pending images as attachments
@@ -354,6 +364,7 @@ export function InlineCreateCard({
setPriority(DEFAULT_TASK_PRIORITY);
setDependencies([]);
setSelectedAgentId(null);
setNodeId(undefined);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
@@ -390,6 +401,7 @@ export function InlineCreateCard({
addToast,
projectId,
selectedPresetId,
nodeId,
]);
const handleKeyDown = useCallback(
@@ -564,6 +576,7 @@ export function InlineCreateCard({
setBrowserVerification(false);
setSelectedPresetId(undefined);
setSelectedAgentId(null);
setNodeId(undefined);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
@@ -588,6 +601,7 @@ export function InlineCreateCard({
setBrowserVerification(false);
setSelectedPresetId(undefined);
setSelectedAgentId(null);
setNodeId(undefined);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
@@ -813,6 +827,27 @@ export function InlineCreateCard({
})()}
</div>
<label className="inline-create-node-wrap" htmlFor="inline-create-node-select">
<span className="visually-hidden">Execution Node Override</span>
<select
id="inline-create-node-select"
className="select inline-create-node-select"
data-testid="inline-create-node-select"
value={nodeId ?? ""}
onChange={(e) => {
const nextNodeId = e.target.value;
setNodeId(nextNodeId || undefined);
}}
>
<option value="">Execution Node: Project default / local</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{`${node.name} (${getNodeStatusLabel(node.status)})`}
</option>
))}
</select>
</label>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"

View File

@@ -3,7 +3,7 @@ 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 } from "../api";
import { batchUpdateTaskModels, updateTask } from "../api";
import type { ModelInfo } from "../api";
import { QuickEntryBox } from "./QuickEntryBox";
import { CustomModelDropdown } from "./CustomModelDropdown";
@@ -12,6 +12,7 @@ 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)",
@@ -24,6 +25,16 @@ 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";
return "Offline";
}
type SortField = "id" | "title" | "status" | "column";
type SortDirection = "asc" | "desc";
@@ -446,7 +457,9 @@ 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 [isApplying, setIsApplying] = useState(false);
const { nodes } = useNodes();
// Handle apply bulk model update
const handleApplyBulkUpdate = useCallback(async () => {
@@ -462,6 +475,10 @@ 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[];
@@ -499,42 +516,77 @@ 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) {
if (Object.keys(payload).length === 1 && !hasNodeChange) {
addToast("No changes to apply", "info");
return;
}
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");
return;
}
setIsApplying(true);
try {
const result = await batchUpdateTaskModels(
payload.taskIds,
payload.modelProvider,
payload.modelId,
payload.validatorModelProvider,
payload.validatorModelId,
undefined,
undefined,
projectId,
);
// Optimistically update parent with returned tasks
if (onTasksUpdated && result.updated.length > 0) {
onTasksUpdated(result.updated);
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);
}
addToast(`Updated ${result.count} task${result.count === 1 ? "" : "s"}`, "success");
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");
}
// Reset state
clearSelection();
setExecutorModel("__no_change__");
setValidatorModel("__no_change__");
setBulkNodeId("__no_change__");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to update models", "error");
} finally {
setIsApplying(false);
}
}, [selectedTaskIds, tasks, executorModel, validatorModel, projectId, addToast, clearSelection, onTasksUpdated]);
}, [selectedTaskIds, tasks, executorModel, validatorModel, bulkNodeId, projectId, addToast, clearSelection, onTasksUpdated]);
const handleRowClick = useCallback(
(task: Task) => {
@@ -681,7 +733,7 @@ export function ListView({
{/* Bulk Edit Toolbar */}
{selectedTaskIds.size > 0 && availableModels && availableModels.length > 0 && (
<div className="bulk-edit-toolbar">
<span className="bulk-edit-label">Bulk Edit Models:</span>
<span className="bulk-edit-label">Bulk Edit Models &amp; Node:</span>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
@@ -710,10 +762,19 @@ export function ListView({
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown">
<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>
))}
</select>
</div>
<button
className="btn btn-primary btn-sm bulk-edit-apply-btn"
onClick={handleApplyBulkUpdate}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__")}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && bulkNodeId === "__no_change__")}
>
{isApplying ? "Applying..." : "Apply"}
</button>

View File

@@ -10,6 +10,7 @@ import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { TaskForm, type PendingImage } from "./TaskForm";
import { useConfirm } from "../hooks/useConfirm";
import { useNodes } from "../hooks/useNodes";
interface NewTaskModalProps {
isOpen: boolean;
@@ -39,6 +40,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
// Agent assignment state
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@@ -53,6 +55,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const quickFieldsDepRef = useRef<HTMLDivElement>(null);
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
const { nodes } = useNodes();
// Handler for workflow step changes that detects explicit user interaction
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
@@ -151,9 +154,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
selectedWorkflowSteps.length > 0 ||
selectedAgentId !== null ||
reviewLevel !== undefined ||
priority !== DEFAULT_TASK_PRIORITY;
priority !== DEFAULT_TASK_PRIORITY ||
nodeId !== undefined;
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId]);
const handleClose = useCallback(async () => {
if (hasDirtyState) {
@@ -182,6 +186,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setShowAgentPicker(false);
setReviewLevel(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setHasDirtyState(false);
onClose();
}, [hasDirtyState, onClose, pendingImages, confirm]);
@@ -215,6 +220,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined,
reviewLevel,
priority,
nodeId,
});
// Upload pending images as attachments
@@ -249,6 +255,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setShowAgentPicker(false);
setReviewLevel(undefined);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
addToast(`Created ${task.id}`, "success");
onClose();
@@ -257,7 +264,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
} finally {
setIsSubmitting(false);
}
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority, nodeId]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -460,6 +467,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onReviewLevelChange={setReviewLevel}
priority={priority}
onPriorityChange={setPriority}
nodeId={nodeId}
onNodeIdChange={setNodeId}
nodeOptions={nodes}
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}

View File

@@ -117,6 +117,15 @@
position: relative;
}
.quick-entry-node-wrap {
display: inline-flex;
align-items: center;
}
.quick-entry-node-select {
max-width: 250px;
}
.quick-entry-subtasks-toggle {
display: inline-flex;
align-items: center;

View File

@@ -9,6 +9,8 @@ import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGl
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { useNodes } from "../hooks/useNodes";
import type { NodeInfo } from "../api";
const STORAGE_KEY = "kb-quick-entry-text";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -61,6 +63,12 @@ interface QuickEntryBoxProps {
onToggleModelFavorite?: (modelId: string) => void;
}
function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online";
if (status === "connecting") return "Connecting";
return "Offline";
}
function getModelSelectionValue(provider?: string, modelId?: string): string {
return provider && modelId ? `${provider}/${modelId}` : "";
}
@@ -139,6 +147,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
const [isFastMode, setIsFastMode] = useState(false);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const { nodes } = useNodes();
// AI Refinement state
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
@@ -380,6 +390,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setPlanningModelId(undefined);
setSelectedPresetId(undefined);
setIsFastMode(false);
setNodeId(undefined);
setShowDeps(false);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
@@ -451,6 +462,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined,
...(isFastMode ? { executionMode: "fast" } : {}),
nodeId,
});
if (createdTask && pendingImages.length > 0) {
const failures: string[] = [];
@@ -496,6 +508,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
addToast,
resetForm,
isFastMode,
nodeId,
]);
const handleKeyDown = useCallback(
@@ -1356,6 +1369,27 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
{modelMenuLabel}
</button>
<label className="quick-entry-node-wrap" htmlFor="quick-entry-node-select">
<span className="visually-hidden">Execution Node Override</span>
<select
id="quick-entry-node-select"
className="select quick-entry-node-select"
data-testid="quick-entry-node-select"
value={nodeId ?? ""}
onChange={(e) => {
const nextNodeId = e.target.value;
setNodeId(nextNodeId || undefined);
}}
>
<option value="">Execution Node: Project default / local</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{`${node.name} (${getNodeStatusLabel(node.status)})`}
</option>
))}
</select>
</label>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"

View File

@@ -27,6 +27,7 @@ import { ProviderIcon } from "./ProviderIcon";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { appendTokenQuery } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
import { useNodes } from "../hooks/useNodes";
// ---------------------------------------------------------------------------
// GitHub star count — fetched once per session, cached in localStorage (1 h).
@@ -35,6 +36,12 @@ const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
const GITHUB_STAR_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string {
if (status === "online") return "Online";
if (status === "connecting") return "Connecting";
return "Offline";
}
/**
* Has the user already clicked the "Star on GitHub" button at any point in
* the past? Used to permanently hide the button afterward — clicking opens
@@ -350,6 +357,7 @@ export function SettingsModal({
refresh: refreshOverlapPathPicker,
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
const { nodes } = useNodes();
const remoteAccessEnabled = isExperimentalFeatureEnabled(form.experimentalFeatures ?? {}, "remoteAccess");
const visibleSections = SETTINGS_SECTIONS.filter((section) => section.id !== "remote" || remoteAccessEnabled);
const firstVisibleSectionId = visibleSections.find((section) => !section.isGroupHeader)?.id ?? "general";
@@ -2355,6 +2363,44 @@ export function SettingsModal({
/>
<small>Maximum concurrent planning agents</small>
</div>
<div className="form-group">
<label htmlFor="defaultNodeId">Default Execution Node</label>
<select
id="defaultNodeId"
className="select"
value={typeof form.defaultNodeId === "string" ? form.defaultNodeId : ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultNodeId: val || undefined } as SettingsFormState));
}}
>
<option value="">Local execution (no default node)</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({getNodeStatusLabel(node.status)})
</option>
))}
</select>
<small>Used when a task has no node override. Node status is shown for safer routing selection.</small>
</div>
<div className="form-group">
<label htmlFor="unavailableNodePolicy">Unavailable Node Policy</label>
<select
id="unavailableNodePolicy"
value={
form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"
}
onChange={(e) =>
setForm((f) => ({
...f,
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
} as SettingsFormState))
}
>
<option value="block">Block execution</option>
<option value="fallback-local">Fallback to local</option>
</select>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
<input

View File

@@ -19,6 +19,7 @@ import { TaskComments } from "./TaskComments";
import { MergeDetails } from "./MergeDetails";
import { TaskChangesTab } from "./TaskChangesTab";
import { TaskForm, type PendingImage } from "./TaskForm";
import { useNodes } from "../hooks/useNodes";
import { WorkflowResultsTab } from "./WorkflowResultsTab";
import { TaskDocumentsTab } from "./TaskDocumentsTab";
import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
@@ -33,6 +34,8 @@ interface ModelSelection {
modelId?: string;
}
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
/**
@@ -357,6 +360,7 @@ export function TaskDetailModal({
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
const [editReviewLevel, setEditReviewLevel] = useState<number | undefined>(undefined);
const [editPriority, setEditPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [editNodeId, setEditNodeId] = useState<string | undefined>(task.nodeId);
const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
@@ -395,6 +399,7 @@ export function TaskDetailModal({
const [workflowResults, setWorkflowResults] = useState<WorkflowStepResult[]>([]);
const [workflowResultsLoading, setWorkflowResultsLoading] = useState(false);
const [workflowEnabledSteps, setWorkflowEnabledSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
const isNodeOverrideLocked = task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string);
// Reset edit state when task changes
useEffect(() => {
@@ -570,6 +575,7 @@ export function TaskDetailModal({
setEditValidatorModel(valModel);
setEditPlanningModel(planModel);
setEditThinkingLevel(task.thinkingLevel ?? "");
setEditNodeId(task.nodeId);
setEditPresetMode(execModel || valModel || planModel ? "custom" : "default");
setEditSelectedPresetId("");
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
@@ -588,6 +594,7 @@ export function TaskDetailModal({
setEditTitle(task.title || "");
setEditDescription(task.description || "");
setEditDependencies(task.dependencies || []);
setEditNodeId(task.nodeId);
setEditSourceIssueProvider(task.sourceIssue?.provider ?? "");
setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
@@ -596,12 +603,12 @@ export function TaskDetailModal({
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setEditPendingImages([]);
}, [task.title, task.description, task.dependencies, task.priority, task.executionMode, editPendingImages]);
}, [task.title, task.description, task.dependencies, task.nodeId, task.priority, task.executionMode, editPendingImages]);
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
const updates: Parameters<typeof updateTask>[1] = {};
const updates: Record<string, unknown> = {};
const trimmedTitle = editTitle.trim();
const trimmedDescription = editDescription.trim();
@@ -643,6 +650,9 @@ export function TaskDetailModal({
if (editThinkingLevel !== currentThinkingLevel) {
updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null;
}
if ((task.nodeId ?? undefined) !== editNodeId) {
updates.nodeId = editNodeId ?? null;
}
const currentReviewLevel = task.reviewLevel;
if (editReviewLevel !== currentReviewLevel) {
@@ -712,7 +722,7 @@ export function TaskDetailModal({
const hasTaskUpdates = Object.keys(updates).length > 0;
if (hasTaskUpdates) {
const updatedTask = await updateTask(task.id, updates, projectId);
const updatedTask = await updateTask(task.id, updates as never, projectId);
onTaskUpdated?.(updatedTask);
}
@@ -744,7 +754,7 @@ export function TaskDetailModal({
setIsSaving(false);
}
}
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editReviewLevel, editPriority, editExecutionMode, editSelectedWorkflowSteps, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueExternalId, editSourceIssueUrl, editPendingImages, addToast, projectId, onTaskUpdated]);
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editNodeId, editReviewLevel, editPriority, editExecutionMode, editSelectedWorkflowSteps, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueExternalId, editSourceIssueUrl, editPendingImages, addToast, projectId, onTaskUpdated]);
const handleAutoSaveDescription = useCallback(async (description: string) => {
try {
@@ -774,6 +784,7 @@ export function TaskDetailModal({
}, [isEditing, handleEditKeyDown]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { nodes } = useNodes();
const { confirm } = useConfirm();
const {
entries: agentLogEntries,
@@ -1337,6 +1348,11 @@ export function TaskDetailModal({
onReviewLevelChange={setEditReviewLevel}
priority={editPriority}
onPriorityChange={setEditPriority}
nodeId={editNodeId}
onNodeIdChange={setEditNodeId}
nodeOptions={nodes}
nodeOverrideDisabled={isNodeOverrideLocked}
nodeOverrideDisabledReason={isNodeOverrideLocked ? "Execution node override is locked while a task is active/in progress." : undefined}
executionMode={editExecutionMode}
onExecutionModeChange={setEditExecutionMode}
renderBelowModelConfiguration={(
@@ -1604,6 +1620,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

@@ -1,11 +1,17 @@
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo } from "../api";
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucide-react";
function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online";
if (status === "connecting") return "Connecting";
return "Offline";
}
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
/** Renders a phase badge using shared .phase-badge classes for consistency */
@@ -40,6 +46,11 @@ export interface TaskFormProps {
// Dependencies
dependencies: string[];
onDependenciesChange: (deps: string[]) => void;
nodeId?: string;
onNodeIdChange?: (nodeId: string | undefined) => void;
nodeOptions?: NodeInfo[];
nodeOverrideDisabled?: boolean;
nodeOverrideDisabledReason?: string;
// Model configuration
priority?: TaskPriority;
@@ -106,6 +117,11 @@ export function TaskForm({
onTitleChange,
dependencies,
onDependenciesChange,
nodeId,
onNodeIdChange,
nodeOptions,
nodeOverrideDisabled = false,
nodeOverrideDisabledReason,
priority,
onPriorityChange,
executorModel,
@@ -154,7 +170,8 @@ export function TaskForm({
(planningModel || "") !== "" ||
(thinkingLevel || "") !== "" ||
reviewLevel !== undefined ||
executionMode === "fast";
executionMode === "fast" ||
(nodeId || "") !== "";
const [showDepDropdown, setShowDepDropdown] = useState(false);
const [showMoreOptions, setShowMoreOptions] = useState(
@@ -219,7 +236,8 @@ export function TaskForm({
(planningModel || "") !== "" ||
(thinkingLevel || "") !== "" ||
reviewLevel !== undefined ||
executionMode === "fast";
executionMode === "fast" ||
(nodeId || "") !== "";
// Auto-select preset by size (create mode only)
useEffect(() => {
@@ -803,6 +821,29 @@ export function TaskForm({
<small>You can also paste images or drag & drop</small>
</div>
{onNodeIdChange && (
<div className="form-group">
<label htmlFor="task-node-select">Execution Node Override</label>
<select
id="task-node-select"
className="select"
value={nodeId ?? ""}
onChange={(e) => onNodeIdChange(e.target.value || undefined)}
disabled={disabled || nodeOverrideDisabled}
>
<option value="">Use project default / local</option>
{(nodeOptions ?? []).map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({getNodeStatusLabel(node.status)})
</option>
))}
</select>
<small>
{nodeOverrideDisabledReason ?? "Task override takes priority over project default node routing."}
</small>
</div>
)}
{!hideDependencies && (
<>
{/* Dependencies */}

View File

@@ -4,6 +4,7 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import { InlineCreateCard } from "../InlineCreateCard";
import type { Task, Column } from "@fusion/core";
import { fetchModels, fetchSettings, fetchAgents } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import type { ModelInfo } from "../../api";
import { scopedKey } from "../../utils/projectStorage";
@@ -83,6 +84,22 @@ vi.mock("../ModelSelectionModal", () => ({
},
}));
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(() => ({
nodes: [
{ id: "node-1", name: "Node One", status: "online", type: "remote", createdAt: "", updatedAt: "" },
{ id: "node-2", name: "Node Two", status: "offline", type: "remote", createdAt: "", updatedAt: "" },
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
})),
}));
// Mock the api module
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
@@ -172,6 +189,19 @@ beforeEach(() => {
localStorage.clear();
vi.mocked(fetchModels).mockResolvedValue({ models: MOCK_MODELS, favoriteProviders: [], favoriteModels: [] });
vi.mocked(fetchAgents).mockResolvedValue([]);
vi.mocked(useNodes).mockReturnValue({
nodes: [
{ id: "node-1", name: "Node One", status: "online", type: "remote", createdAt: "", updatedAt: "" },
{ id: "node-2", name: "Node Two", status: "offline", type: "remote", createdAt: "", updatedAt: "" },
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
});
vi.mocked(fetchSettings).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
@@ -1365,4 +1395,22 @@ describe("InlineCreateCard button visibility when collapsed", () => {
});
});
});
describe("InlineCreateCard node override", () => {
it("includes nodeId in payload when execution node override is selected", async () => {
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-777" } as Task);
renderCard([], { onSubmit });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Run on node" } });
expandCard();
fireEvent.change(screen.getByTestId("inline-create-node-select"), { target: { value: "node-1" } });
fireEvent.click(screen.getByTestId("save-button"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "node-1" }));
});
});
});
});

View File

@@ -2022,7 +2022,7 @@ describe("ListView - Bulk Selection", () => {
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
expect(screen.getByText("Bulk Edit Models:")).toBeDefined();
expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined();
});
it("shows bulk edit toolbar when tasks are selected", () => {
@@ -2044,7 +2044,7 @@ describe("ListView - Bulk Selection", () => {
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
expect(screen.getByText("Bulk Edit Models:")).toBeDefined();
expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined();
});
it("disables apply button when no model changes selected", () => {
@@ -2403,7 +2403,7 @@ describe("ListView - Bulk Selection", () => {
fireEvent.click(screen.getByLabelText("Select FN-002"));
expect(screen.getByText("2 selected")).toBeInTheDocument();
expect(screen.getByText("Bulk Edit Models:")).toBeInTheDocument();
expect(screen.getByText("Bulk Edit Models & Node:")).toBeInTheDocument();
});
it("applies agent-active class to mobile cards when task is in-progress and not paused/failed", () => {

View File

@@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import { QuickEntryBox } from "../QuickEntryBox";
import type { Task } from "@fusion/core";
import { fetchSettings, fetchAgents, uploadAttachment } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import { scopedKey } from "../../utils/projectStorage";
const MOCK_MODELS = [
@@ -101,6 +102,22 @@ vi.mock("../../api", () => ({
updateGlobalSettings: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(() => ({
nodes: [
{ id: "node-1", name: "Node One", status: "online", type: "remote", createdAt: "", updatedAt: "" },
{ id: "node-2", name: "Node Two", status: "offline", type: "remote", createdAt: "", updatedAt: "" },
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
})),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
Link: () => null,
@@ -218,6 +235,19 @@ describe("QuickEntryBox", () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
vi.mocked(fetchAgents).mockResolvedValue([]);
vi.mocked(useNodes).mockReturnValue({
nodes: [
{ id: "node-1", name: "Node One", status: "online", type: "remote", createdAt: "", updatedAt: "" },
{ id: "node-2", name: "Node Two", status: "offline", type: "remote", createdAt: "", updatedAt: "" },
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
});
vi.mocked(uploadAttachment).mockResolvedValue({} as any);
Object.defineProperty(URL, "createObjectURL", {
@@ -2984,4 +3014,18 @@ describe("QuickEntryBox", () => {
expect(wrapper).toHaveClass("quick-entry-textarea-wrap");
});
});
it("includes nodeId in payload when execution node override is selected", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
renderQuickEntryBox({ onCreate });
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Route this task" } });
expandQuickEntry();
fireEvent.change(screen.getByTestId("quick-entry-node-select"), { target: { value: "node-2" } });
clickSave();
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "node-2" }));
});
});
});

View File

@@ -3059,6 +3059,73 @@ describe("PATCH /tasks/:id", () => {
expect(res.body.dependencies).toEqual(["FN-002"]);
});
it("returns 409 when changing nodeId on an in-progress task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "in-progress",
nodeId: "node-old",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: "node-xyz" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(409);
expect(res.body.error).toContain("in progress");
expect(store.updateTask).not.toHaveBeenCalled();
});
it("allows changing nodeId on a todo task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "todo",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, nodeId: "node-xyz" });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: "node-xyz" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: "node-xyz" });
});
it("allows clearing nodeId on a todo task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "todo",
nodeId: "node-old",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, nodeId: undefined });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: null }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { nodeId: null });
});
it("returns 409 when clearing nodeId on an in-progress task", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
column: "in-progress",
nodeId: "node-old",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ nodeId: null }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(409);
expect(res.body.error).toContain("in progress");
expect(store.updateTask).not.toHaveBeenCalled();
});
it("forwards sourceIssue object updates to store.updateTask", async () => {
const sourceIssue = {
provider: "github",

View File

@@ -1,6 +1,6 @@
import { createReadStream } from "node:fs";
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, validateNodeOverrideChange } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -1235,7 +1235,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.patch("/tasks/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue } = req.body;
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId } = req.body;
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
// Validate model fields are strings or undefined/null
@@ -1341,6 +1341,18 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (hasBodyField("reviewLevel")) updates.reviewLevel = reviewLevel;
if (hasBodyField("executionMode")) updates.executionMode = executionMode === null ? null : executionMode;
if (hasBodyField("sourceIssue")) updates.sourceIssue = validatedSourceIssue === undefined ? undefined : validatedSourceIssue;
if (hasBodyField("nodeId")) updates.nodeId = nodeId === null ? null : nodeId;
if (hasBodyField("nodeId")) {
const currentTask = await scopedStore.getTask(req.params.id);
if (!currentTask) {
throw notFound("Task not found");
}
const validation = validateNodeOverrideChange(currentTask, nodeId ?? null);
if (!validation.allowed) {
throw new ApiError(409, validation.message ?? "Node override change blocked");
}
}
const task = await scopedStore.updateTask(req.params.id, updates);
res.json(task);