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
{/* 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 ? (
+
+ ) : (
+
+ )}
-
@@ -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
// ---------------------------------------------------------------------------