diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index b8f2fcdee1..b25d2f9c90 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -307,6 +307,73 @@ border-style: dashed; } +/* ── Step-inversion nodes (KTD-3/4/12/15, U8) ── */ + +.wf-node-step-execute { + border-color: var(--accent, var(--ws-info)); +} + +.wf-node-step-review { + border-color: var(--ws-info); +} + +.wf-node-parse-steps { + border-color: var(--ws-info); +} + +.wf-node-code { + border-color: var(--text-muted); + font-family: var(--font-mono, monospace); +} + +/* A foreach renders as a React Flow group node containing its template + * subgraph. Children are positioned inside the group's box. */ +.wf-foreach-group { + width: 100%; + height: 100%; + box-sizing: border-box; + border: 1px dashed var(--accent, var(--ws-info)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--accent, var(--ws-info)) 6%, transparent); + padding: var(--space-xs); +} + +.wf-foreach-group.wf-node--error { + border-color: var(--ws-error); +} + +.wf-foreach-header { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.8rem; + color: var(--text); +} + +.wf-foreach-empty { + margin-top: var(--space-sm); + padding: var(--space-sm); + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + font-size: 0.7rem; + color: var(--text-muted); + text-align: center; +} + +/* Rework edges (KTD-5): dashed accent stroke with a loop affordance. */ +.wf-edge-rework .react-flow__edge-path { + stroke: var(--accent, var(--ws-info)); + stroke-dasharray: 5 4; + stroke-width: 2; +} + +.wf-code-source { + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + white-space: pre; + overflow-x: auto; +} + .wf-node-icon { display: inline-flex; color: var(--text-muted); diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 38b08b9f4a..6d3996dadd 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -15,7 +15,7 @@ import { type Edge as FlowEdge, } from "@xyflow/react"; import { useTranslation } from "react-i18next"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge } from "lucide-react"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react"; import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { @@ -46,6 +46,12 @@ import { validateColumnsClient, unplacedNodeIds, isColumnBandNode, + foreachChildFlowId, + shortConditionLabel, + FOREACH_GROUP_WIDTH, + FOREACH_GROUP_HEIGHT, + FOREACH_CHILD_X, + FOREACH_CHILD_Y, } from "./workflow-flow-mapping"; import { fetchTraits, type TraitCatalogEntry } from "../api"; import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; @@ -84,6 +90,14 @@ function newNodeId(): string { return `n-${Date.now().toString(36)}-${nodeSeq}`; } +/** Built-in step parsers (KTD-12). Hardcoded for now; TODO: source from the live + * parser registry once a catalog endpoint exists (incl. plugin parsers). */ +const BUILTIN_STEP_PARSERS = ["step-headings", "json-steps"] as const; + +/** Step-review verdict outcomes (KTD-4), authored as `outcome:` edge + * conditions and displayed as short labels. */ +const STEP_REVIEW_VERDICTS = ["approve", "revise", "rethink", "unavailable"] as const; + 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 } }, @@ -93,6 +107,11 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof { kind: "hold", label: "Hold", icon: PauseCircle, presetConfig: { release: "manual" } }, { kind: "split", label: "Split", icon: Split }, { kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } }, + // Step-inversion (KTD-3/4/12/15). + { kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } }, + { kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } }, + { kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } }, + { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, ]; function InnerEditor({ @@ -109,6 +128,7 @@ function InnerEditor({ const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); const { t } = useTranslation("app"); // v2 columns the editor is authoring for the active workflow. const [columns, setColumns] = useState([]); @@ -172,6 +192,7 @@ function InnerEditor({ setEdges(flow.edges); setColumns(columnsOf(activeWorkflow)); setSelectedNodeId(null); + setSelectedEdgeId(null); setValidationError(null); }, [activeWorkflow, setNodes, setEdges]); @@ -219,6 +240,41 @@ function InnerEditor({ 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; + + if (kind === "foreach") { + // A foreach renders as a React Flow group node. It auto-populates ONE + // step-execute child (a prompt node with seam=step-execute) so the group + // is never confusingly empty (KTD-3 / U8). The group node must precede + // its child in the array for React Flow's parent extent to apply. + const childId = foreachChildFlowId(id, newNodeId()); + setNodes((ns) => [ + ...ns, + { + id, + type: "foreach", + position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 }, + data: { kind: "foreach", label, config, templateEmpty: false }, + style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT }, + deletable: true, + }, + { + id: childId, + type: "prompt", + position: { x: FOREACH_CHILD_X, y: FOREACH_CHILD_Y }, + parentId: id, + extent: "parent", + data: { + kind: "prompt", + label: t("workflowNodes.stepExecuteLabel", "Step execute"), + config: { seam: "step-execute" }, + }, + deletable: true, + }, + ]); + setSelectedNodeId(id); + return; + } + setNodes((ns) => [ ...ns, { @@ -231,7 +287,7 @@ function InnerEditor({ ]); setSelectedNodeId(id); }, - [setNodes], + [setNodes, t], ); const updateSelectedData = useCallback( @@ -269,6 +325,30 @@ function InnerEditor({ [selectedNodeId, setNodes], ); + // Edge inspector (KTD-4/5): mutate the selected edge's condition + rework + // kind, keeping its display label in sync. Rework edges render dashed/animated. + const updateSelectedEdge = useCallback( + (patch: { condition?: string; rework?: boolean }) => { + if (!selectedEdgeId) return; + setEdges((eds) => + eds.map((e) => { + if (e.id !== selectedEdgeId) return e; + const condition = patch.condition ?? (e.data?.condition as string | undefined) ?? "success"; + const rework = patch.rework ?? (e.data?.kind as string | undefined) === "rework"; + return { + ...e, + label: rework ? `${shortConditionLabel(condition)} (rework)` : shortConditionLabel(condition), + data: { ...(e.data ?? {}), condition, kind: rework ? "rework" : undefined }, + type: rework ? "step" : undefined, + animated: rework, + className: rework ? "wf-edge-rework" : undefined, + }; + }), + ); + }, + [selectedEdgeId, setEdges], + ); + const handleCreateWorkflow = useCallback(async () => { const name = window.prompt("New workflow name"); if (!name?.trim()) return; @@ -383,16 +463,54 @@ function InnerEditor({ // (WorkflowNodeErrorBadge) renders both, keyed off data.errorBadge. const nodesForRender = useMemo(() => { const unplacedSet = new Set(unplaced); + // Count current template children per foreach group so the empty-state hint + // (KTD-3 / U8) reflects live deletions even though the palette seeds one. + const childCount = new Map(); + for (const n of nodes) { + if (n.parentId) childCount.set(n.parentId, (childCount.get(n.parentId) ?? 0) + 1); + } + const emptyHint = t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here"); return nodes.map((n) => { let errorBadge: string | undefined; if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column"); if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message; - if (errorBadge === n.data.errorBadge) return n; - return { ...n, data: { ...n.data, errorBadge } }; + const templateEmpty = n.data.kind === "foreach" ? (childCount.get(n.id) ?? 0) === 0 : undefined; + if ( + errorBadge === n.data.errorBadge && + (n.data.kind !== "foreach" || (templateEmpty === n.data.templateEmpty && n.data.emptyHint === emptyHint)) + ) + return n; + return { + ...n, + data: { + ...n.data, + errorBadge, + ...(n.data.kind === "foreach" ? { templateEmpty, emptyHint } : {}), + }, + }; }); }, [nodes, unplaced, serverNodeError, t]); const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null; + const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null; + // The edge inspector's verdict/rework controls apply only when the edge's + // source node is a step-review node (KTD-4). + const selectedEdgeSourceIsReview = useMemo(() => { + if (!selectedEdge) return false; + const src = nodes.find((n) => n.id === selectedEdge.source); + return src?.data.kind === "step-review"; + }, [selectedEdge, nodes]); + + // Artifacts the active workflow declares (KTD-12). The parse-steps inspector + // offers a select over these; when none are declared it falls back to a + // free-text input defaulting to PROMPT.md. + const declaredArtifacts = useMemo(() => { + const ir = activeWorkflow?.ir; + if (ir && ir.version === "v2" && Array.isArray(ir.artifacts)) { + return ir.artifacts.map((a) => a.key); + } + return []; + }, [activeWorkflow]); // Lazy-loaded executor resources const [models, setModels] = useState([]); @@ -402,6 +520,13 @@ function InnerEditor({ const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model"; useEffect(() => { + // step-review offers an optional review model picker (KTD-4). + if (selectedNode?.data.kind === "step-review" && models.length === 0) { + fetchModels().then((res) => setModels(res.models)).catch((err) => { + addToast(getErrorMessage(err) || "Failed to load models", "error"); + }); + return; + } 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) => { @@ -527,8 +652,18 @@ function InnerEditor({ onEdgesChange={onEdgesChange} onConnect={onConnect} onNodeDragStop={onNodeDragStop} - onNodeClick={(_, node) => setSelectedNodeId(node.id)} - onPaneClick={() => setSelectedNodeId(null)} + onNodeClick={(_, node) => { + setSelectedNodeId(node.id); + setSelectedEdgeId(null); + }} + onEdgeClick={(_, edge) => { + setSelectedEdgeId(edge.id); + setSelectedNodeId(null); + }} + onPaneClick={() => { + setSelectedNodeId(null); + setSelectedEdgeId(null); + }} fitView > @@ -837,6 +972,258 @@ function InnerEditor({

) : null} + {selectedNode.data.kind === "foreach" ? ( + (() => { + const mode = String(selectedNode.data.config?.mode ?? "sequential"); + const isParallel = mode === "parallel"; + return ( + <> + + + + + {isParallel && ( + + )} + + +

+ {t( + "workflowNodes.foreachNote", + "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.", + )} +

+ + ); + })() + ) : null} + + {selectedNode.data.kind === "step-review" ? ( + <> + + +

+ {t( + "workflowNodes.reviewNote", + "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.", + )} +

+ + ) : null} + + {selectedNode.data.kind === "parse-steps" ? ( + <> + {declaredArtifacts.length > 0 ? ( + + ) : ( + + )} + + + ) : null} + + {selectedNode.data.kind === "code" ? ( + <> +