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:
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
68
packages/core/src/__tests__/node-override-guard.test.ts
Normal file
68
packages/core/src/__tests__/node-override-guard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
26
packages/core/src/node-override-guard.ts
Normal file
26
packages/core/src/node-override-guard.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user