diff --git a/.changeset/graph-custom-workflows.md b/.changeset/graph-custom-workflows.md index e19e1e1efc..82656cf3d1 100644 --- a/.changeset/graph-custom-workflows.md +++ b/.changeset/graph-custom-workflows.md @@ -3,3 +3,5 @@ --- Add executable custom workflows with a visual graph node editor. Author a workflow as a graph (start → prompt/script/gate steps → end) in a new React Flow–based editor, then select it per task or set a project default. Selected workflows compile to the existing WorkflowStep engine and run at the pre/post-merge boundaries — no changes to the scheduler/executor/merger. Non-linear graphs are rejected with a clear message and reserved for the (deferred) graph interpreter. + +Prompt nodes carry an execution profile: run on a chosen model, as a named agent, as a skill invocation, or as a named project script (CLI) with the prompt passed via FUSION_NODE_PROMPT — plus per-node retries and an auto-approve toggle. "User input" nodes pause the run with a needs-input badge on the task card and a banner in the task modal; replying in comments and unpausing resumes the workflow with the answer. diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 53051a23e8..42edbbfb4d 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -287,6 +287,17 @@ color: var(--triage); } +.card-status-badge.awaiting-input { + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: var(--color-warning); + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); +} + +.card.awaiting-input { + border-left: 3px solid var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 6%, transparent); +} + .card.stuck { border-left: 3px solid var(--triage); background: color-mix(in srgb, var(--triage) 6%, transparent); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index a2a0e9932a..f8c8c71b6c 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -844,8 +844,9 @@ function TaskCardComponent({ const hasTaskAgeStaleness = shouldShowTaskAgeStalenessBadge(task); const taskAgeStalenessCopy = getTaskAgeStalenessCopy(task.ageStaleness); const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; + const isAwaitingInput = task.status === "awaiting-user-input"; const isArchived = task.column === "archived"; - const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); + const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && !isAwaitingInput && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit/archived or host embedding // Check if this card can be edited inline @@ -1557,7 +1558,7 @@ function TaskCardComponent({ } }, [addToast, isRetrying, onRetryTask, task.id]); - const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`; + const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isAwaitingInput ? " awaiting-input" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`; const filesChangedButton = (() => { if (task.column === "in-progress") { @@ -1705,6 +1706,11 @@ function TaskCardComponent({ {pausedByAgent ? "paused by agent" : "paused"} )} + {isAwaitingInput && ( + + Needs input + + )} {!isPaused && visualStatus && visualStatus !== "queued" && ( ) : activeTab === "model" ? ( diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 5f301e061f..3909061b42 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -239,6 +239,27 @@ color: var(--text-tertiary); } +.wf-inspector-note--info { + color: var(--ws-warning); +} + +.wf-field--checkbox { + flex-direction: row; + align-items: center; + gap: var(--space-sm); + cursor: pointer; +} + +.wf-field--checkbox input[type="checkbox"] { + width: auto; + padding: 0; + margin: 0; + background: none; + border: none; + accent-color: var(--todo); + cursor: pointer; +} + /* Canvas nodes */ .wf-node { display: inline-flex; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index d9a5315fc7..1a188497fc 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -14,7 +14,7 @@ import { type Node as FlowNode, type Edge as FlowEdge, } from "@xyflow/react"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2 } from "lucide-react"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle } from "lucide-react"; import type { WorkflowDefinition } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { @@ -23,12 +23,32 @@ import { updateWorkflow, deleteWorkflow, compileWorkflow, + fetchModels, + fetchAgents, + fetchDiscoveredSkills, + type ModelInfo, } from "../api"; +import type { Agent } from "../api"; +import type { DiscoveredSkill } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "./workflow-flow-mapping"; +import { CustomModelDropdown } from "./CustomModelDropdown"; + +type ExecutorKind = "model" | "agent" | "skill" | "cli"; + +function getModelDropdownValue(provider: string, modelId: string): string { + return provider && modelId ? `${provider}/${modelId}` : ""; +} + +function parseModelDropdownValue(value: string): { provider: string; modelId: string } { + if (!value) return { provider: "", modelId: "" }; + const slashIndex = value.indexOf("/"); + if (slashIndex === -1) return { provider: "", modelId: "" }; + return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1) }; +} interface WorkflowNodeEditorProps { isOpen: boolean; @@ -43,8 +63,9 @@ function newNodeId(): string { return `n-${Date.now().toString(36)}-${nodeSeq}`; } -const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare }> = [ +const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record }> = [ { kind: "prompt", label: "Prompt", icon: MessageSquare }, + { kind: "prompt", label: "User input", icon: HelpCircle, presetConfig: { awaitInput: true } }, { kind: "script", label: "Script", icon: Terminal }, { kind: "gate", label: "Gate", icon: Shield }, { kind: "merge", label: "Merge boundary", icon: GitMerge }, @@ -108,16 +129,18 @@ function InnerEditor({ ); const addNode = useCallback( - (kind: WorkflowEditorNodeKind) => { + (kind: WorkflowEditorNodeKind, nodeLabel?: string, presetConfig?: Record) => { const id = newNodeId(); - const label = kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1); + const label = nodeLabel ?? (kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1)); + const baseConfig = kind === "gate" ? { gateMode: "gate" } : {}; + const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig; setNodes((ns) => [ ...ns, { id, type: kind, position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 }, - data: { kind, label, config: kind === "gate" ? { gateMode: "gate" } : {} }, + data: { kind, label, config }, deletable: true, }, ]); @@ -200,6 +223,32 @@ function InnerEditor({ }, [activeWorkflow, nodes, edges, projectId, addToast]); const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null; + + // Lazy-loaded executor resources + const [models, setModels] = useState([]); + const [agents, setAgents] = useState([]); + const [skills, setSkills] = useState([]); + + const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model"; + + useEffect(() => { + if (!selectedNode || (selectedNode.data.kind !== "prompt" && selectedNode.data.kind !== "gate")) return; + if (currentExecutor === "model" && models.length === 0) { + fetchModels().then((res) => setModels(res.models)).catch((err) => { + addToast(getErrorMessage(err) || "Failed to load models", "error"); + }); + } else if (currentExecutor === "agent" && agents.length === 0) { + fetchAgents().then(setAgents).catch((err) => { + addToast(getErrorMessage(err) || "Failed to load agents", "error"); + }); + } else if (currentExecutor === "skill" && skills.length === 0) { + fetchDiscoveredSkills(projectId).then(setSkills).catch((err) => { + addToast(getErrorMessage(err) || "Failed to load skills", "error"); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentExecutor, selectedNode?.id]); + const overlayProps = useOverlayDismiss(onClose); return ( @@ -244,8 +293,8 @@ function InnerEditor({ <> - {PALETTE.map(({ kind, label, icon: Icon }) => ( - addNode(kind)}> + {PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => ( + addNode(kind, label, presetConfig)}> {label} ))} @@ -313,6 +362,127 @@ function InnerEditor({ ) : null} + {selectedNode.data.kind === "prompt" ? ( + <> + + Executor + updateSelectedData({ config: { executor: e.target.value } })} + > + Model + Agent + Skill + CLI / script + + + + {currentExecutor === "model" && ( + + Model + { + const { provider, modelId } = parseModelDropdownValue(value); + updateSelectedData({ config: { modelProvider: provider || undefined, modelId: modelId || undefined } }); + }} + /> + + )} + + {currentExecutor === "agent" && ( + + Agent + updateSelectedData({ config: { agentId: e.target.value || undefined } })} + > + — select agent — + {agents.map((a) => ( + {a.name} + ))} + + + )} + + {currentExecutor === "skill" && ( + + Skill + updateSelectedData({ config: { skillName: e.target.value || undefined } })} + > + — select skill — + {skills.map((s) => ( + {s.name} + ))} + + + )} + + {currentExecutor === "cli" && ( + + Script name + updateSelectedData({ config: { scriptName: e.target.value } })} + /> + Named script from project settings. The node prompt is passed via FUSION_NODE_PROMPT. + + )} + + + updateSelectedData({ config: { autoApprove: e.target.checked } })} + /> + Auto-approve requests + + + + Max retries + { + const val = e.target.value.trim(); + if (val === "") { + const patch: Record = { ...selectedNode.data.config }; + delete patch.maxRetries; + updateSelectedData({ config: patch }); + } else { + const num = parseInt(val, 10); + if (!isNaN(num)) updateSelectedData({ config: { maxRetries: num } }); + } + }} + /> + + + + updateSelectedData({ config: { awaitInput: e.target.checked } })} + /> + Wait for user input + + {Boolean(selectedNode.data.config?.awaitInput) && ( + + This node pauses the task until you reply in the task's comments and unpause. The Prompt field above is shown to the user as the question. + + )} + > + ) : null} + {selectedNode.data.kind === "script" ? ( Script name diff --git a/packages/dashboard/app/components/WorkflowResultsTab.css b/packages/dashboard/app/components/WorkflowResultsTab.css index 6c1238c1ad..d06c161683 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.css +++ b/packages/dashboard/app/components/WorkflowResultsTab.css @@ -1,4 +1,17 @@ /* ── Workflow Results ── */ + +.workflow-input-banner { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + background: var(--bg-secondary); + border-bottom: 1px solid var(--ws-warning); + color: var(--ws-warning); + font-size: 0.85rem; + margin-bottom: var(--space-sm); +} + .workflow-results-loading { display: flex; align-items: center; diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index a83f15fedb..59e88beef4 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -42,6 +42,17 @@ interface WorkflowResultsTabProps { projectId?: string; isTaskInProgress?: boolean; onWorkflowStepsChange?: (steps: string[]) => void; + taskStatus?: string; + taskPausedReason?: string; +} + +/** Extract the user-facing question from a workflow-input paused reason. + * Strips the leading "workflow-input:: " prefix if present. */ +function parseWorkflowInputQuestion(pausedReason?: string): string { + if (!pausedReason) return "Reply in the comments and unpause the task to continue."; + const match = /^workflow-input:[^:]+:\s*(.*)$/s.exec(pausedReason); + if (match) return match[1].trim() || "Reply in the comments and unpause the task to continue."; + return pausedReason; } interface WorkflowStepOption { @@ -201,6 +212,8 @@ export function WorkflowResultsTab({ projectId, isTaskInProgress, onWorkflowStepsChange, + taskStatus, + taskPausedReason, }: WorkflowResultsTabProps) { const [expandedOutputs, setExpandedOutputs] = useState>({}); const [renderModes, setRenderModes] = useState>({}); @@ -659,8 +672,16 @@ export function WorkflowResultsTab({ const showConfiguredStepsState = !loading && !hasResults && hasConfiguredSteps; const showEditHeaderForResults = canEdit && hasResults; + const isAwaitingInput = taskStatus === "awaiting-user-input"; + return ( + {isAwaitingInput && ( + + Waiting for your input + {parseWorkflowInputQuestion(taskPausedReason)} + + )} {canEdit && onWorkflowStepsChange && (
+ This node pauses the task until you reply in the task's comments and unpause. The Prompt field above is shown to the user as the question. +