From 4c81ba1de8c12186fb7555f1d6ad48eb8c912c53 Mon Sep 17 00:00:00 2001 From: David Culbreth Date: Mon, 23 Feb 2026 22:51:49 -0600 Subject: [PATCH] workflow builder, first edition --- web/src/components/common/ParamSchemaForm.tsx | 9 +- .../components/workflows/TaskInspector.tsx | 259 ++++---------- web/src/components/workflows/TaskNode.tsx | 145 ++++---- .../components/workflows/WorkflowCanvas.tsx | 17 +- .../components/workflows/WorkflowEdges.tsx | 80 +++-- web/src/pages/actions/WorkflowBuilderPage.tsx | 321 +++++++++++++++--- web/src/types/workflow.ts | 111 +++++- 7 files changed, 583 insertions(+), 359 deletions(-) diff --git a/web/src/components/common/ParamSchemaForm.tsx b/web/src/components/common/ParamSchemaForm.tsx index c59f7fd..eb4c0fb 100644 --- a/web/src/components/common/ParamSchemaForm.tsx +++ b/web/src/components/common/ParamSchemaForm.tsx @@ -68,6 +68,12 @@ interface ParamSchemaFormProps { * at enforcement time rather than set to literal values. */ allowTemplates?: boolean; + /** + * When true, hides the amber "Template expressions" info banner at the top + * while still enabling template-mode inputs. Useful when template syntax is + * supported but the contextual hint (rule-specific namespaces) doesn't apply. + */ + hideTemplateHint?: boolean; } /** @@ -140,6 +146,7 @@ export default function ParamSchemaForm({ disabled = false, className = "", allowTemplates = false, + hideTemplateHint = false, }: ParamSchemaFormProps) { const [localErrors, setLocalErrors] = useState>({}); @@ -450,7 +457,7 @@ export default function ParamSchemaForm({ return (
- {allowTemplates && ( + {allowTemplates && !hideTemplateHint && (

Template expressions are diff --git a/web/src/components/workflows/TaskInspector.tsx b/web/src/components/workflows/TaskInspector.tsx index fe03098..47b0ed5 100644 --- a/web/src/components/workflows/TaskInspector.tsx +++ b/web/src/components/workflows/TaskInspector.tsx @@ -8,6 +8,7 @@ import { GripVertical, ArrowRight, Palette, + Loader2, } from "lucide-react"; import type { WorkflowTask, @@ -19,6 +20,7 @@ import type { import { PRESET_WHEN, PRESET_LABELS, + PRESET_COLORS, classifyTransitionWhen, transitionLabel, } from "@/types/workflow"; @@ -26,6 +28,7 @@ import ParamSchemaForm, { extractProperties, type ParamSchema, } from "@/components/common/ParamSchemaForm"; +import { useAction } from "@/hooks/useActions"; /** Preset color swatches for quick transition color selection */ const TRANSITION_COLOR_SWATCHES = [ @@ -67,6 +70,10 @@ export default function TaskInspector({ onClose, highlightTransitionIndex, }: TaskInspectorProps) { + // Fetch full action details (including param_schema) on demand + const { data: actionDetail, isLoading: actionLoading } = useAction( + task.action || "", + ); const transitionRefs = useRef>(new Map()); const [flashIndex, setFlashIndex] = useState(null); const [expandedSections, setExpandedSections] = useState>( @@ -187,6 +194,8 @@ export default function TaskInspector({ if (preset) { const whenExpr = PRESET_WHEN[preset]; if (whenExpr) newTransition.when = whenExpr; + newTransition.label = PRESET_LABELS[preset]; + newTransition.color = PRESET_COLORS[preset]; } next.push(newTransition); update({ next }); @@ -263,11 +272,12 @@ export default function TaskInspector({ [task.next, update], ); - // Get the selected action's param schema + // Get the selected action's param schema from fetched action detail const selectedAction = availableActions.find((a) => a.ref === task.action); + const fetchedAction = actionDetail?.data; const actionParamSchema: ParamSchema = useMemo( - () => (selectedAction?.param_schema as ParamSchema) || {}, - [selectedAction?.param_schema], + () => (fetchedAction?.param_schema as ParamSchema) || {}, + [fetchedAction?.param_schema], ); const schemaProperties = useMemo( () => extractProperties(actionParamSchema), @@ -275,30 +285,6 @@ export default function TaskInspector({ ); const hasSchema = Object.keys(schemaProperties).length > 0; - // Separate task inputs into schema-driven and extra custom params - const schemaKeys = useMemo( - () => new Set(Object.keys(schemaProperties)), - [schemaProperties], - ); - const schemaValues = useMemo(() => { - const vals: Record = {}; - for (const key of schemaKeys) { - if (task.input[key] !== undefined) { - vals[key] = task.input[key]; - } - } - return vals; - }, [task.input, schemaKeys]); - const extraParams = useMemo(() => { - const extras: Record = {}; - for (const [key, value] of Object.entries(task.input)) { - if (!schemaKeys.has(key)) { - extras[key] = value; - } - } - return extras; - }, [task.input, schemaKeys]); - const otherTaskNames = allTaskNames.filter((n) => n !== task.name); return ( @@ -375,14 +361,20 @@ export default function TaskInspector({ ))} - {selectedAction?.description && ( + {(fetchedAction?.description || selectedAction?.description) && (

- {selectedAction.description} + {fetchedAction?.description || selectedAction?.description}

)}
{/* Input Parameters — schema-driven form */} + {task.action && actionLoading && ( +
+ + Loading parameters… +
+ )} {hasSchema && (
{ - // Merge schema-driven values back with extra params - update({ input: { ...newValues, ...extraParams } }); + update({ input: newValues }); }} allowTemplates + hideTemplateHint className="text-xs" />
)} - - {/* Extra custom parameters (not in schema) */} - {hasSchema && Object.keys(extraParams).length > 0 && ( -
- -
- {Object.entries(extraParams).map(([key, value]) => ( -
-
- - {key} - - -
- - update({ - input: { ...task.input, [key]: e.target.value }, - }) - } - className="w-full px-2 py-1 border border-gray-300 rounded text-xs font-mono focus:ring-1 focus:ring-blue-500 focus:border-blue-500" - placeholder="{{ parameters.value }} or literal" - /> -
- ))} -
-
+ {task.action && !actionLoading && !hasSchema && ( +

+ This action has no declared parameters. +

)} - - {/* No schema — free-form input editing */} - {!hasSchema && task.action && ( -
- -
- {Object.entries(task.input).map(([key, value]) => ( -
-
- - {key} - - -
- - update({ - input: { ...task.input, [key]: e.target.value }, - }) - } - className="w-full px-2 py-1 border border-gray-300 rounded text-xs font-mono focus:ring-1 focus:ring-blue-500 focus:border-blue-500" - placeholder="{{ parameters.value }} or literal" - /> -
- ))} -
-
- )} - - {/* Add custom parameter button */} -
@@ -850,48 +733,50 @@ export default function TaskInspector({

- {task.with_items && ( - <> -
- - - update({ - batch_size: e.target.value - ? parseInt(e.target.value) - : undefined, - }) - } - className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - placeholder="Process all at once" - min={1} - /> -
-
- - - update({ - concurrency: e.target.value - ? parseInt(e.target.value) - : undefined, - }) - } - className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - placeholder="No limit" - min={1} - /> -
- - )} +
+ + + update({ + batch_size: e.target.value + ? parseInt(e.target.value) + : undefined, + }) + } + className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed" + placeholder="Process all at once" + min={1} + /> +
+
+ + + update({ + concurrency: e.target.value + ? parseInt(e.target.value) + : undefined, + }) + } + className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed" + placeholder="No limit" + min={1} + /> +
diff --git a/web/src/components/workflows/TaskNode.tsx b/web/src/components/workflows/TaskNode.tsx index 0904336..79deff0 100644 --- a/web/src/components/workflows/TaskNode.tsx +++ b/web/src/components/workflows/TaskNode.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useRef, useState } from "react"; -import { Trash2, Settings, GripVertical } from "lucide-react"; +import { Trash2, GripVertical, Play, Octagon } from "lucide-react"; import type { WorkflowTask, TransitionPreset } from "@/types/workflow"; import { PRESET_LABELS, @@ -12,6 +12,7 @@ export type { TransitionPreset }; interface TaskNodeProps { task: WorkflowTask; isSelected: boolean; + isStartNode: boolean; allTaskNames: string[]; onSelect: (taskId: string) => void; onDelete: (taskId: string) => void; @@ -95,6 +96,7 @@ function transitionSummary(task: WorkflowTask): string | null { function TaskNodeInner({ task, isSelected, + isStartNode, onSelect, onDelete, onPositionChange, @@ -107,7 +109,6 @@ function TaskNodeInner({ const [hoveredHandle, setHoveredHandle] = useState( null, ); - const [isInputHandleHovered, setIsInputHandleHovered] = useState(false); const dragOffset = useRef({ x: 0, y: 0 }); const handleMouseDown = useCallback( @@ -147,9 +148,9 @@ function TaskNodeInner({ const handleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - if (connectingFrom && connectingFrom.taskId !== task.id) { + if (connectingFrom) { onCompleteConnection(task.id); - } else if (!connectingFrom) { + } else { onSelect(task.id); } }, @@ -173,18 +174,7 @@ function TaskNodeInner({ [task.id, onStartConnection], ); - const handleInputHandleMouseUp = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (connectingFrom && connectingFrom.taskId !== task.id) { - onCompleteConnection(task.id); - } - }, - [task.id, connectingFrom, onCompleteConnection], - ); - - const isConnectionTarget = - connectingFrom !== null && connectingFrom.taskId !== task.id; + const isConnectionTarget = connectingFrom !== null; const borderColor = isSelected ? "border-blue-500 ring-2 ring-blue-200" @@ -195,6 +185,12 @@ function TaskNodeInner({ const hasAction = task.action && task.action.length > 0; const summary = transitionSummary(task); + // A stop node has no outgoing transitions to other tasks + const isStopNode = + !task.next || + task.next.length === 0 || + task.next.every((t) => !t.do || t.do.length === 0); + // Count custom transitions (those not matching any preset) const customTransitionCount = (task.next || []).filter((t) => { const ct = classifyTransitionWhen(t.when); @@ -212,55 +208,35 @@ function TaskNodeInner({ }} onMouseDown={handleMouseDown} onClick={handleClick} + onMouseUp={(e) => { + if (connectingFrom) { + e.stopPropagation(); + onCompleteConnection(task.id); + } + }} > - {/* Input handle (top center) — drop target */} -
setIsInputHandleHovered(true)} - onMouseLeave={() => setIsInputHandleHovered(false)} - > -
-
-
{/* Header */} -
- +
+ {isStartNode ? ( + + ) : ( + + )}
-
+
{task.name}
@@ -322,35 +298,40 @@ function TaskNodeInner({
{/* Footer actions */} -
-
- - -
+
+ {isStopNode && ( +
+ + Stop +
+ )} +
{/* Connection target overlay */} {isConnectionTarget && (
- Drop to connect + {connectingFrom?.taskId === task.id + ? "Drop to self-loop" + : "Drop to connect"}
)} diff --git a/web/src/components/workflows/WorkflowCanvas.tsx b/web/src/components/workflows/WorkflowCanvas.tsx index 0f6d14b..9d0767f 100644 --- a/web/src/components/workflows/WorkflowCanvas.tsx +++ b/web/src/components/workflows/WorkflowCanvas.tsx @@ -3,15 +3,12 @@ import TaskNode from "./TaskNode"; import type { TransitionPreset } from "./TaskNode"; import WorkflowEdges from "./WorkflowEdges"; import type { EdgeHoverInfo } from "./WorkflowEdges"; -import type { - WorkflowTask, - PaletteAction, - WorkflowEdge, -} from "@/types/workflow"; +import type { WorkflowTask, WorkflowEdge } from "@/types/workflow"; import { deriveEdges, generateUniqueTaskName, generateTaskId, + findStartingTaskIds, PRESET_LABELS, } from "@/types/workflow"; import { Plus } from "lucide-react"; @@ -19,7 +16,6 @@ import { Plus } from "lucide-react"; interface WorkflowCanvasProps { tasks: WorkflowTask[]; selectedTaskId: string | null; - availableActions: PaletteAction[]; onSelectTask: (taskId: string | null) => void; onUpdateTask: (taskId: string, updates: Partial) => void; onDeleteTask: (taskId: string) => void; @@ -29,7 +25,7 @@ interface WorkflowCanvasProps { preset: TransitionPreset, toTaskName: string, ) => void; - onEdgeHover?: (info: EdgeHoverInfo | null) => void; + onEdgeClick?: (info: EdgeHoverInfo | null) => void; } /** Label color mapping for the connecting banner */ @@ -47,7 +43,7 @@ export default function WorkflowCanvas({ onDeleteTask, onAddTask, onSetConnection, - onEdgeHover, + onEdgeClick, }: WorkflowCanvasProps) { const canvasRef = useRef(null); const [connectingFrom, setConnectingFrom] = useState<{ @@ -63,6 +59,8 @@ export default function WorkflowCanvas({ const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]); + const startingTaskIds = useMemo(() => findStartingTaskIds(tasks), [tasks]); + const handleCanvasClick = useCallback( (e: React.MouseEvent) => { // Only deselect if clicking the canvas background @@ -213,7 +211,7 @@ export default function WorkflowCanvas({ tasks={tasks} connectingFrom={connectingFrom} mousePosition={mousePosition} - onEdgeHover={onEdgeHover} + onEdgeClick={onEdgeClick} /> {/* Task nodes */} @@ -222,6 +220,7 @@ export default function WorkflowCanvas({ key={task.id} task={task} isSelected={task.id === selectedTaskId} + isStartNode={startingTaskIds.has(task.id)} allTaskNames={allTaskNames} onSelect={onSelectTask} onDelete={onDeleteTask} diff --git a/web/src/components/workflows/WorkflowEdges.tsx b/web/src/components/workflows/WorkflowEdges.tsx index f1c902d..521a60e 100644 --- a/web/src/components/workflows/WorkflowEdges.tsx +++ b/web/src/components/workflows/WorkflowEdges.tsx @@ -1,5 +1,6 @@ import { memo, useMemo } from "react"; import type { WorkflowEdge, WorkflowTask, EdgeType } from "@/types/workflow"; +import { PRESET_COLORS } from "@/types/workflow"; import type { TransitionPreset } from "./TaskNode"; export interface EdgeHoverInfo { @@ -18,8 +19,8 @@ interface WorkflowEdgesProps { connectingFrom?: { taskId: string; preset: TransitionPreset } | null; /** Mouse position for drawing the preview connection line */ mousePosition?: { x: number; y: number } | null; - /** Called when the mouse enters/leaves an edge hit area */ - onEdgeHover?: (info: EdgeHoverInfo | null) => void; + /** Called when an edge is clicked */ + onEdgeClick?: (info: EdgeHoverInfo | null) => void; } const NODE_WIDTH = 240; @@ -40,13 +41,6 @@ const EDGE_DASH: Record = { custom: "8,4,2,4", }; -/** Map presets to edge colors for the preview line */ -const PRESET_COLORS: Record = { - succeeded: EDGE_COLORS.success, - failed: EDGE_COLORS.failure, - always: EDGE_COLORS.complete, -}; - /** Calculate the center-bottom of a task node */ function getNodeBottomCenter( task: WorkflowTask, @@ -89,14 +83,31 @@ function getNodeRightCenter( /** * Determine the best connection points between two nodes. - * Returns the start and end points for the edge. + * Returns the start and end points for the edge, plus whether this is a + * self-loop (from === to). */ function getBestConnectionPoints( fromTask: WorkflowTask, toTask: WorkflowTask, nodeWidth: number, nodeHeight: number, -): { start: { x: number; y: number }; end: { x: number; y: number } } { +): { + start: { x: number; y: number }; + end: { x: number; y: number }; + selfLoop?: boolean; +} { + // Self-loop: start from right side, end at top-right area + if (fromTask.id === toTask.id) { + return { + start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight), + end: { + x: fromTask.position.x + nodeWidth * 0.75, + y: fromTask.position.y, + }, + selfLoop: true, + }; + } + const fromCenter = { x: fromTask.position.x + nodeWidth / 2, y: fromTask.position.y + nodeHeight / 2, @@ -117,10 +128,15 @@ function getBestConnectionPoints( }; } - // If the target is mostly above the source, use top→bottom + // If the target is mostly above the source, use a side edge (never top). + // Pick the side closer to the target, and arrive at the target's bottom. if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) { + const start = + dx >= 0 + ? getNodeRightCenter(fromTask, nodeWidth, nodeHeight) + : getNodeLeftCenter(fromTask, nodeHeight); return { - start: getNodeTopCenter(fromTask, nodeWidth), + start, end: getNodeBottomCenter(toTask, nodeWidth, nodeHeight), }; } @@ -140,6 +156,21 @@ function getBestConnectionPoints( }; } +/** + * Build an SVG path for a self-loop: exits from the right side, arcs out + * to the right and upward, then curves back to the top of the node. + */ +function buildSelfLoopPath( + start: { x: number; y: number }, + end: { x: number; y: number }, +): string { + const loopOffset = 50; + // Control points: push out to the right and then curve up and back + const cp1 = { x: start.x + loopOffset, y: start.y - 20 }; + const cp2 = { x: end.x + loopOffset, y: end.y - 40 }; + return `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`; +} + /** * Build an SVG path string for a curved edge between two points. * Uses a cubic bezier curve. @@ -179,7 +210,7 @@ function WorkflowEdgesInner({ nodeHeight = NODE_HEIGHT, connectingFrom, mousePosition, - onEdgeHover, + onEdgeClick, }: WorkflowEdgesProps) { const taskMap = useMemo(() => { const map = new Map(); @@ -211,21 +242,25 @@ function WorkflowEdgesInner({ const toTask = taskMap.get(edge.to); if (!fromTask || !toTask) return null; - const { start, end } = getBestConnectionPoints( + const { start, end, selfLoop } = getBestConnectionPoints( fromTask, toTask, nodeWidth, nodeHeight, ); - const pathD = buildCurvePath(start, end); + const pathD = selfLoop + ? buildSelfLoopPath(start, end) + : buildCurvePath(start, end); const color = edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete; const dash = EDGE_DASH[edge.type] || ""; - // Calculate label position (midpoint of curve) - const labelX = (start.x + end.x) / 2; - const labelY = (start.y + end.y) / 2 - 8; + // Calculate label position — offset to the right for self-loops + const labelX = selfLoop ? start.x + 50 : (start.x + end.x) / 2; + const labelY = selfLoop + ? (start.y + end.y) / 2 - 20 + : (start.y + end.y) / 2 - 8; // Measure approximate label width const labelText = edge.label || ""; @@ -254,13 +289,12 @@ function WorkflowEdgesInner({ stroke="transparent" strokeWidth={12} className="cursor-pointer" - onMouseEnter={() => - onEdgeHover?.({ + onClick={() => + onEdgeClick?.({ taskId: edge.from, transitionIndex: edge.transitionIndex, }) } - onMouseLeave={() => onEdgeHover?.(null)} /> {/* Label */} {edge.label && ( @@ -295,7 +329,7 @@ function WorkflowEdgesInner({ ); }) .filter(Boolean); - }, [edges, taskMap, nodeWidth, nodeHeight, onEdgeHover]); + }, [edges, taskMap, nodeWidth, nodeHeight, onEdgeClick]); // Preview line when connecting const previewLine = useMemo(() => { diff --git a/web/src/pages/actions/WorkflowBuilderPage.tsx b/web/src/pages/actions/WorkflowBuilderPage.tsx index cfaf113..2a14140 100644 --- a/web/src/pages/actions/WorkflowBuilderPage.tsx +++ b/web/src/pages/actions/WorkflowBuilderPage.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback, useMemo, useRef } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { useQueries } from "@tanstack/react-query"; import { ArrowLeft, Save, @@ -7,6 +8,7 @@ import { FileCode, Code, LayoutDashboard, + X, } from "lucide-react"; import yaml from "js-yaml"; import type { WorkflowYamlDefinition } from "@/types/workflow"; @@ -15,6 +17,7 @@ import WorkflowCanvas from "@/components/workflows/WorkflowCanvas"; import type { EdgeHoverInfo } from "@/components/workflows/WorkflowEdges"; import TaskInspector from "@/components/workflows/TaskInspector"; import { useActions } from "@/hooks/useActions"; +import { ActionsService } from "@/api"; import { usePacks } from "@/hooks/usePacks"; import { useWorkflow } from "@/hooks/useWorkflows"; import { @@ -35,6 +38,8 @@ import { validateWorkflow, addTransitionTarget, removeTaskFromTransitions, + renameTaskInTransitions, + findStartingTaskIds, } from "@/types/workflow"; const INITIAL_STATE: WorkflowBuilderState = { @@ -82,28 +87,20 @@ export default function WorkflowBuilderPage() { taskId: string; transitionIndex: number; } | null>(null); - const highlightTimeoutRef = useRef | null>( - null, - ); - const handleEdgeHover = useCallback( + // Start-node warning toast state + const [startWarningVisible, setStartWarningVisible] = useState(false); + const [startWarningDismissed, setStartWarningDismissed] = useState(false); + const [showSaveConfirm, setShowSaveConfirm] = useState(false); + const [prevWarningKey, setPrevWarningKey] = useState(null); + const [justInitialized, setJustInitialized] = useState(false); + + const handleEdgeClick = useCallback( (info: EdgeHoverInfo | null) => { - // Clear any pending auto-clear timeout - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current); - highlightTimeoutRef.current = null; - } - if (info) { // Select the source task so TaskInspector opens for it setSelectedTaskId(info.taskId); setHighlightedTransition(info); - - // Auto-clear highlight after 2 seconds so the flash animation plays once - highlightTimeoutRef.current = setTimeout(() => { - setHighlightedTransition(null); - highlightTimeoutRef.current = null; - }, 2000); } else { setHighlightedTransition(null); } @@ -138,6 +135,7 @@ export default function WorkflowBuilderPage() { ); setState(builderState); setInitialized(true); + setJustInitialized(true); } } @@ -149,8 +147,6 @@ export default function WorkflowBuilderPage() { label: string; description?: string; pack_ref: string; - param_schema?: Record | null; - out_schema?: Record | null; }>; return actions.map((a) => ({ id: a.id, @@ -158,19 +154,42 @@ export default function WorkflowBuilderPage() { label: a.label, description: a.description || "", pack_ref: a.pack_ref, - param_schema: a.param_schema || null, - out_schema: a.out_schema || null, })); }, [actionsData]); - // Build action schema map for stripping defaults during serialization + // Fetch full action details for every unique action ref used in the workflow. + // React Query caches each response, so repeated refs don't cause extra requests. + const uniqueActionRefs = useMemo(() => { + const refs = new Set(); + for (const task of state.tasks) { + if (task.action) refs.add(task.action); + } + return [...refs]; + }, [state.tasks]); + + const actionDetailQueries = useQueries({ + queries: uniqueActionRefs.map((ref) => ({ + queryKey: ["actions", ref], + queryFn: () => ActionsService.getAction({ ref }), + staleTime: 30_000, + enabled: !!ref, + })), + }); + + // Build action schema map from individually-fetched action details const actionSchemaMap = useMemo(() => { const map = new Map | null>(); - for (const action of paletteActions) { - map.set(action.ref, action.param_schema); + for (let i = 0; i < uniqueActionRefs.length; i++) { + const query = actionDetailQueries[i]; + if (query.data?.data) { + map.set( + uniqueActionRefs[i], + (query.data.data.param_schema as Record) || null, + ); + } } return map; - }, [paletteActions]); + }, [uniqueActionRefs, actionDetailQueries]); const packs = useMemo(() => { return (packsData?.data || []) as Array<{ @@ -190,6 +209,65 @@ export default function WorkflowBuilderPage() { [state.tasks], ); + const startingTaskIds = useMemo( + () => findStartingTaskIds(state.tasks), + [state.tasks], + ); + + const startNodeWarning = useMemo(() => { + if (state.tasks.length === 0) return null; + const count = startingTaskIds.size; + if (count === 0) + return { + level: "error" as const, + message: + "No starting tasks found — every task is a target of another transition, so the workflow has no entry point.", + }; + if (count > 1) + return { + level: "warn" as const, + message: `${count} starting tasks found (${state.tasks + .filter((t) => startingTaskIds.has(t.id)) + .map((t) => `"${t.name}"`) + .join(", ")}). Workflows typically have a single entry point.`, + }; + return null; + }, [state.tasks, startingTaskIds]); + + // Render-phase state adjustment: detect warning key changes for immediate + // show/hide without refs or synchronous setState inside effects. + const warningKey = startNodeWarning + ? `${startNodeWarning.level}:${startingTaskIds.size}` + : null; + + if (warningKey !== prevWarningKey) { + setPrevWarningKey(warningKey); + + if (!warningKey) { + // Condition resolved → immediately hide and allow future warnings + if (startWarningVisible) setStartWarningVisible(false); + if (startWarningDismissed) setStartWarningDismissed(false); + } else if (justInitialized) { + // Loaded from persistent storage with a problem → show immediately + if (!startWarningVisible) setStartWarningVisible(true); + setJustInitialized(false); + } + } + + // Debounce timer: starts a 15-second countdown for non-initial warning + // appearances. The timer callback is async so it satisfies React's rules. + useEffect(() => { + if (!startNodeWarning || startWarningVisible || startWarningDismissed) { + return; + } + + const timer = setTimeout(() => { + setStartWarningVisible(true); + }, 15_000); + + return () => clearTimeout(timer); + }, [startNodeWarning, startWarningVisible, startWarningDismissed]); + // State updaters const updateMetadata = useCallback( (updates: Partial) => { @@ -214,20 +292,11 @@ export default function WorkflowBuilderPage() { } } - // Pre-populate input from action's param_schema - const input: Record = {}; - if (action.param_schema && typeof action.param_schema === "object") { - for (const [key, param] of Object.entries(action.param_schema)) { - const meta = param as { default?: unknown }; - input[key] = meta?.default !== undefined ? meta.default : ""; - } - } - const newTask: WorkflowTask = { id: generateTaskId(), name, action: action.ref, - input, + input: {}, position: { x: 300, y: state.tasks.length === 0 ? 60 : maxY + 160, @@ -254,12 +323,49 @@ export default function WorkflowBuilderPage() { const handleUpdateTask = useCallback( (taskId: string, updates: Partial) => { - setState((prev) => ({ - ...prev, - tasks: prev.tasks.map((t) => - t.id === taskId ? { ...t, ...updates } : t, - ), - })); + setState((prev) => { + // Detect a name change so we can propagate it to transitions + const oldName = + updates.name !== undefined + ? prev.tasks.find((t) => t.id === taskId)?.name + : undefined; + const newName = updates.name; + const isRename = + oldName !== undefined && newName !== undefined && oldName !== newName; + + return { + ...prev, + tasks: prev.tasks.map((t) => { + if (t.id === taskId) { + // Apply the updates, then also fix self-referencing transitions + const merged = { ...t, ...updates }; + if (isRename) { + const updatedNext = renameTaskInTransitions( + merged.next, + oldName, + newName, + ); + if (updatedNext !== merged.next) { + return { ...merged, next: updatedNext }; + } + } + return merged; + } + // Update transition `do` lists that reference the old name + if (isRename) { + const updatedNext = renameTaskInTransitions( + t.next, + oldName, + newName, + ); + if (updatedNext !== t.next) { + return { ...t, next: updatedNext }; + } + } + return t; + }), + }; + }); setSaveSuccess(false); }, [], @@ -310,7 +416,7 @@ export default function WorkflowBuilderPage() { [], ); - const handleSave = useCallback(async () => { + const doSave = useCallback(async () => { // Validate const errors = validateWorkflow(state); setValidationErrors(errors); @@ -381,6 +487,18 @@ export default function WorkflowBuilderPage() { actionSchemaMap, ]); + const handleSave = useCallback(() => { + // If there's a start-node problem, show the toast immediately and + // require confirmation before saving + if (startNodeWarning) { + setStartWarningVisible(true); + setStartWarningDismissed(false); + setShowSaveConfirm(true); + return; + } + doSave(); + }, [startNodeWarning, doSave]); + // YAML preview — generate proper YAML from builder state const yamlPreview = useMemo(() => { if (!showYamlPreview) return ""; @@ -640,13 +758,12 @@ export default function WorkflowBuilderPage() { {/* Right: Task Inspector */} @@ -667,6 +784,122 @@ export default function WorkflowBuilderPage() { )}
+ + {/* Floating start-node warning toast */} + {startNodeWarning && startWarningVisible && ( +
+
+ +

+ {startNodeWarning.message} +

+ +
+
+ )} + + {/* Confirmation modal for saving with start-node warnings */} + {showSaveConfirm && startNodeWarning && ( +
+ {/* Backdrop */} +
setShowSaveConfirm(false)} + /> + {/* Modal */} +
+
+
+ +
+
+

+ {startNodeWarning.level === "error" + ? "No starting tasks" + : "Multiple starting tasks"} +

+

+ {startNodeWarning.message} Are you sure you want to save this + workflow? +

+
+ + +
+
+
+
+
+ )} + + {/* Inline style for fade-in animation */} +
); } diff --git a/web/src/types/workflow.ts b/web/src/types/workflow.ts index 8808e5d..8a8e44f 100644 --- a/web/src/types/workflow.ts +++ b/web/src/types/workflow.ts @@ -105,6 +105,13 @@ export const PRESET_LABELS: Record = { always: "Always", }; +/** Default edge colors for each preset */ +export const PRESET_COLORS: Record = { + succeeded: "#22c55e", // green-500 + failed: "#ef4444", // red-500 + always: "#6b7280", // gray-500 +}; + /** * Classify a `when` expression into an edge visual type. * Used for edge coloring and labeling. @@ -197,15 +204,27 @@ export interface WorkflowYamlDefinition { tags?: string[]; } +/** Chart-only metadata for a transition edge (not consumed by the backend) */ +export interface TransitionChartMeta { + /** Custom display label for the transition */ + label?: string; + /** Custom color for the transition edge (CSS color string) */ + color?: string; +} + /** Transition as represented in YAML format */ export interface WorkflowYamlTransition { when?: string; publish?: PublishDirective[]; do?: string[]; - /** Custom display label for the transition */ - label?: string; - /** Custom color for the transition edge */ - color?: string; + /** Visual metadata (label, color) — ignored by backend */ + __chart_meta__?: TransitionChartMeta; +} + +/** Chart-only metadata for a task node (not consumed by the backend) */ +export interface TaskChartMeta { + /** Visual position on the canvas */ + position?: NodePosition; } /** Task as represented in YAML format */ @@ -221,6 +240,8 @@ export interface WorkflowYamlTask { timeout?: number; next?: WorkflowYamlTransition[]; join?: number; + /** Visual metadata (position) — ignored by backend */ + __chart_meta__?: TaskChartMeta; } /** Request to save a workflow file to disk and sync to DB */ @@ -254,8 +275,6 @@ export interface PaletteAction { label: string; description: string; pack_ref: string; - param_schema: Record | null; - out_schema: Record | null; } // --------------------------------------------------------------------------- @@ -352,6 +371,11 @@ export function builderStateToDefinition( if (task.timeout) yamlTask.timeout = task.timeout; if (task.join) yamlTask.join = task.join; + // Persist canvas position in __chart_meta__ so layout is restored on reload + yamlTask.__chart_meta__ = { + position: { x: task.position.x, y: task.position.y }, + }; + // Serialize transitions as `next` array if (task.next && task.next.length > 0) { yamlTask.next = task.next.map((t) => { @@ -359,8 +383,12 @@ export function builderStateToDefinition( if (t.when) yt.when = t.when; if (t.publish && t.publish.length > 0) yt.publish = t.publish; if (t.do && t.do.length > 0) yt.do = t.do; - if (t.label) yt.label = t.label; - if (t.color) yt.color = t.color; + // Store label/color in __chart_meta__ to avoid polluting the transition namespace + if (t.label || t.color) { + yt.__chart_meta__ = {}; + if (t.label) yt.__chart_meta__.label = t.label; + if (t.color) yt.__chart_meta__.color = t.color; + } return yt; }); } @@ -499,8 +527,8 @@ export function definitionToBuilderState( when: t.when, publish: t.publish, do: t.do, - label: t.label, - color: t.color, + label: t.__chart_meta__?.label, + color: t.__chart_meta__?.color, })); } else { const converted = legacyTransitionsToNext(task); @@ -520,7 +548,7 @@ export function definitionToBuilderState( batch_size: task.batch_size, concurrency: task.concurrency, join: task.join, - position: { + position: task.__chart_meta__?.position ?? { x: 300, y: 80 + index * 160, }, @@ -623,8 +651,11 @@ export function findOrCreateTransition( return { next, index: existingIndex }; } - // Create new transition - const newTransition: TaskTransition = {}; + // Create new transition with default label and color for the preset + const newTransition: TaskTransition = { + label: PRESET_LABELS[preset], + color: PRESET_COLORS[preset], + }; if (whenExpr) newTransition.when = whenExpr; next.push(newTransition); return { next, index: next.length - 1 }; @@ -677,6 +708,60 @@ export function removeTaskFromTransitions( return cleaned.length > 0 ? cleaned : undefined; } +/** + * Rename a task in all transition `do` lists. + * Returns a new array (or undefined) only when something changed; + * otherwise returns the original reference so callers can cheaply + * detect a no-op via `===`. + */ +export function renameTaskInTransitions( + next: TaskTransition[] | undefined, + oldName: string, + newName: string, +): TaskTransition[] | undefined { + if (!next) return undefined; + + let changed = false; + const updated = next.map((t) => { + if (!t.do || !t.do.includes(oldName)) return t; + changed = true; + return { + ...t, + do: t.do.map((name) => (name === oldName ? newName : name)), + }; + }); + + return changed ? updated : next; +} + +/** + * Find "starting" tasks — those whose name does not appear in any + * transition `do` list (i.e. no other task transitions into them). + * Returns a Set of task IDs. + */ +export function findStartingTaskIds(tasks: WorkflowTask[]): Set { + // Collect every task name that is referenced as a transition target + const targeted = new Set(); + for (const task of tasks) { + if (!task.next) continue; + for (const t of task.next) { + if (t.do) { + for (const name of t.do) { + targeted.add(name); + } + } + } + } + + const startIds = new Set(); + for (const task of tasks) { + if (!targeted.has(task.name)) { + startIds.add(task.id); + } + } + return startIds; +} + // --------------------------------------------------------------------------- // Utility functions // ---------------------------------------------------------------------------