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 (
+ <>
+
+ {t("workflowNodes.foreachMode", "Mode")}
+ {
+ const v = e.target.value;
+ // parallel+shared is rejected by the validator; flip
+ // isolation to worktree when switching to parallel.
+ updateSelectedData({
+ config: (prev) => ({
+ ...prev,
+ mode: v,
+ ...(v === "parallel" && prev.isolation === "shared"
+ ? { isolation: "worktree" }
+ : {}),
+ }),
+ });
+ }}
+ >
+ {t("workflowNodes.foreachSequential", "Sequential")}
+ {t("workflowNodes.foreachParallel", "Parallel")}
+
+
+
+
+ {t("workflowNodes.foreachIsolation", "Isolation")}
+ updateSelectedData({ config: { isolation: e.target.value } })}
+ >
+
+ {t("workflowNodes.foreachShared", "Shared worktree")}
+
+ {t("workflowNodes.foreachWorktree", "Per-step worktree")}
+
+
+
+ {isParallel && (
+
+ {t("workflowNodes.foreachConcurrency", "Concurrency")}
+ {
+ const val = e.target.value.trim();
+ if (val === "") {
+ updateSelectedData({
+ config: (prev) => {
+ const next = { ...prev };
+ delete next.concurrency;
+ return next;
+ },
+ });
+ } else {
+ const num = parseInt(val, 10);
+ if (!isNaN(num)) updateSelectedData({ config: { concurrency: num } });
+ }
+ }}
+ />
+
+ )}
+
+
+ {t("workflowNodes.foreachMaxRework", "Max rework cycles")}
+ {
+ const val = e.target.value.trim();
+ if (val === "") {
+ updateSelectedData({
+ config: (prev) => {
+ const next = { ...prev };
+ delete next.maxReworkCycles;
+ return next;
+ },
+ });
+ } else {
+ const num = parseInt(val, 10);
+ if (!isNaN(num)) updateSelectedData({ config: { maxReworkCycles: num } });
+ }
+ }}
+ />
+
+
+ {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.reviewType", "Review type")}
+ updateSelectedData({ config: { type: e.target.value } })}
+ >
+ {t("workflowNodes.reviewPlan", "Plan review")}
+ {t("workflowNodes.reviewCode", "Code review")}
+
+
+
+ {t("workflowNodes.reviewModel", "Review model (optional)")}
+ {
+ const { provider, modelId } = parseModelDropdownValue(value);
+ updateSelectedData({
+ config: {
+ modelProvider: provider || undefined,
+ modelId: modelId || undefined,
+ model: value || undefined,
+ },
+ });
+ }}
+ />
+
+
+ {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 ? (
+
+ {t("workflowNodes.parseArtifact", "Artifact")}
+ updateSelectedData({ config: { artifact: e.target.value } })}
+ >
+ {declaredArtifacts.map((a) => (
+
+ {a}
+
+ ))}
+
+
+ ) : (
+
+ {t("workflowNodes.parseArtifact", "Artifact")}
+ updateSelectedData({ config: { artifact: e.target.value } })}
+ />
+
+ )}
+
+ {t("workflowNodes.parseParser", "Parser")}
+ {/* TODO: source from the live parser registry (incl. plugin parsers). */}
+ updateSelectedData({ config: { parser: e.target.value } })}
+ >
+ {BUILTIN_STEP_PARSERS.map((p) => (
+
+ {p}
+
+ ))}
+
+
+ >
+ ) : null}
+
+ {selectedNode.data.kind === "code" ? (
+ <>
+
+ {t("workflowNodes.codeSource", "Source (TypeScript)")}
+
+
+ {t("workflowNodes.codeTimeout", "Timeout (ms)")}
+ {
+ const val = e.target.value.trim();
+ if (val === "") {
+ updateSelectedData({
+ config: (prev) => {
+ const next = { ...prev };
+ delete next.timeoutMs;
+ return next;
+ },
+ });
+ } else {
+ const num = parseInt(val, 10);
+ if (!isNaN(num)) updateSelectedData({ config: { timeoutMs: num } });
+ }
+ }}
+ />
+
+
+ {t(
+ "workflowNodes.codeNote",
+ "Runs sandboxed TypeScript. Syntax is validated at save.",
+ )}
+
+ >
+ ) : null}
+
{selectedNode.data.kind === "prompt" ||
selectedNode.data.kind === "gate" ||
selectedNode.data.kind === "script" ? (
@@ -866,6 +1253,62 @@ function InnerEditor({
)}
+
+ {selectedEdge && (
+
+ {t("workflowNodes.edgeInspector", "Edge")}
+
+ {selectedEdgeSourceIsReview ? (
+ <>
+
+ {t("workflowNodes.edgeVerdict", "Review verdict")}
+ {
+ const c = String(selectedEdge.data?.condition ?? "success");
+ return c.startsWith("outcome:") ? c.slice("outcome:".length) : "";
+ })()}
+ onChange={(e) => {
+ const v = e.target.value;
+ updateSelectedEdge({ condition: v ? `outcome:${v}` : "success" });
+ }}
+ >
+ {t("workflowNodes.edgeNoVerdict", "— success (no verdict) —")}
+ {STEP_REVIEW_VERDICTS.map((v) => (
+
+ {v}
+
+ ))}
+
+
+
+ updateSelectedEdge({ rework: e.target.checked })}
+ />
+ {t("workflowNodes.edgeRework", "Rework edge (loop back, bounded)")}
+
+
+ {t(
+ "workflowNodes.edgeReworkNote",
+ "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
+ )}
+
+ >
+ ) : (
+
+ {t(
+ "workflowNodes.edgeConditionLabel",
+ "Condition: {{condition}}",
+ { condition: String(selectedEdge.data?.condition ?? "success") },
+ )}
+
+ )}
+
+
+ )}
diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
index 890a643c95..3264d34529 100644
--- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
+++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
@@ -16,7 +16,7 @@ vi.mock("../../api", () => ({
}));
import { fireEvent } from "@testing-library/react";
-import { fetchWorkflows, fetchTraits, updateWorkflow, compileWorkflow, createWorkflow } from "../../api";
+import { fetchWorkflows, fetchTraits, updateWorkflow, compileWorkflow, createWorkflow, fetchModels } from "../../api";
import type { TraitCatalogEntry } from "../../api";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
@@ -255,3 +255,221 @@ describe("WorkflowNodeEditor — U10 columns/traits/holds", () => {
expect((updates as { ir: { columns: unknown[] } }).ir.columns).toHaveLength(2);
});
});
+
+// ── U8: step-inversion authoring (foreach/step-review/parse-steps/code) ──────
+
+/** A custom v2 workflow with a foreach (one step-execute child + a step-review)
+ * so the editor's group/template + edge inspector surfaces have something to
+ * render and round-trip. */
+function stepwiseDef(): WorkflowDefinition {
+ return {
+ id: "WF-STEP",
+ name: "Stepwise",
+ description: "",
+ ir: {
+ version: "v2",
+ name: "Stepwise",
+ columns: [
+ { id: "plan", name: "Plan", traits: [{ trait: "intake" }] },
+ { id: "in-progress", name: "In progress", traits: [] },
+ { id: "done", name: "Done", traits: [{ trait: "complete" }] },
+ ],
+ artifacts: [{ key: "PROMPT.md", role: "step-source" }],
+ nodes: [
+ { id: "start", kind: "start", column: "plan" },
+ { id: "parse", kind: "parse-steps", column: "plan", config: { artifact: "PROMPT.md", parser: "step-headings" } },
+ {
+ id: "loop",
+ kind: "foreach",
+ column: "in-progress",
+ config: {
+ source: "task-steps",
+ mode: "sequential",
+ isolation: "shared",
+ template: {
+ nodes: [
+ { id: "exec", kind: "prompt", config: { seam: "step-execute" } },
+ { id: "review", kind: "step-review", config: { type: "code" } },
+ ],
+ edges: [
+ { from: "exec", to: "review", condition: "success" },
+ { from: "review", to: "exec", condition: "outcome:approve" },
+ ],
+ },
+ },
+ },
+ { id: "end", kind: "end", column: "done" },
+ ],
+ edges: [
+ { from: "start", to: "parse", condition: "success" },
+ { from: "parse", to: "loop", condition: "success" },
+ { from: "loop", to: "end", condition: "success" },
+ ],
+ },
+ layout: {},
+ createdAt: "2026-06-04T00:00:00.000Z",
+ updatedAt: "2026-06-04T00:00:00.000Z",
+ };
+}
+
+describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
+ beforeEach(() => {
+ vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
+ });
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("offers the new step-inversion palette entries (i18n defaults present)", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
+ render( {}} addToast={() => {}} />);
+ await screen.findByText("Save");
+ expect(screen.getByText("For-each step")).toBeInTheDocument();
+ expect(screen.getByText("Step review")).toBeInTheDocument();
+ expect(screen.getByText("Parse steps")).toBeInTheDocument();
+ expect(screen.getByText("Code")).toBeInTheDocument();
+ });
+
+ it("auto-populates a step-execute child when a foreach is added from the palette", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
+ vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
+ vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
+
+ render( {}} addToast={() => {}} />);
+ await screen.findByText("Save");
+ // Adding a foreach renders a group node with an empty inspector hint absent
+ // (it has a child) and an inspector for the foreach.
+ fireEvent.click(screen.getByText("For-each step").closest("button")!);
+ await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument());
+ // The foreach inspector shows the Mode select (KTD-3).
+ expect(screen.getByText("Mode")).toBeInTheDocument();
+ // No empty-state hint because the palette seeded a step-execute child.
+ expect(screen.queryByTestId("wf-foreach-empty")).not.toBeInTheDocument();
+
+ // Save and assert the foreach round-trips with exactly one step-execute child.
+ await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
+ fireEvent.click(screen.getByText("Save").closest("button")!);
+ await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
+ const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
+ const ir = (updates as { ir: { nodes: { kind: string; config?: Record }[] } }).ir;
+ const foreach = ir.nodes.find((n) => n.kind === "foreach");
+ expect(foreach).toBeTruthy();
+ const template = foreach!.config!.template as { nodes: { config?: Record }[] };
+ expect(template.nodes).toHaveLength(1);
+ expect(template.nodes[0].config?.seam).toBe("step-execute");
+ });
+
+ it("edits foreach mode/isolation/concurrency/maxReworkCycles inspector fields", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
+ render( {}} addToast={() => {}} />);
+ const group = await screen.findByTestId("wf-node-foreach");
+ fireEvent.click(group);
+
+ const modeSel = (await screen.findByText("Mode")).parentElement!.querySelector("select")!;
+ // Switching to parallel flips isolation away from the (now disabled) shared
+ // option and reveals the concurrency input.
+ fireEvent.change(modeSel, { target: { value: "parallel" } });
+ await waitFor(() => expect(screen.getByText("Concurrency")).toBeInTheDocument());
+ const isoSel = screen.getByText("Isolation").parentElement!.querySelector("select")! as HTMLSelectElement;
+ expect(isoSel.value).toBe("worktree");
+ const sharedOpt = isoSel.querySelector('option[value="shared"]') as HTMLOptionElement;
+ expect(sharedOpt.disabled).toBe(true);
+
+ const maxRework = screen.getByText("Max rework cycles").parentElement!.querySelector("input")!;
+ fireEvent.change(maxRework, { target: { value: "5" } });
+ expect((maxRework as HTMLInputElement).value).toBe("5");
+ });
+
+ it("edits step-review type and shows the verdict edge inspector with a rework toggle", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
+ vi.mocked(fetchModels).mockResolvedValue({ models: [] });
+ render( {}} addToast={() => {}} />);
+ // Select the step-review template child.
+ const reviewNode = await screen.findByTestId("wf-node-step-review");
+ fireEvent.click(reviewNode);
+ const typeSel = (await screen.findByText("Review type")).parentElement!.querySelector("select")! as HTMLSelectElement;
+ expect(typeSel.value).toBe("code");
+ fireEvent.change(typeSel, { target: { value: "plan" } });
+ expect(typeSel.value).toBe("plan");
+ });
+
+ it("round-trips a rework edge created/removed via the edge inspector contract", () => {
+ // React Flow does not render edges under jsdom (it needs measured node
+ // dimensions), so the in-browser edge-click path is exercised at the mapping
+ // level: the edge inspector's only effect is to stamp `data.kind` (rework)
+ // and the `outcome:` condition onto the selected flow edge; flowToIr
+ // must fold that into the foreach template as kind:"rework". (The full
+ // template round-trip — including rework edges — is covered in
+ // workflow-flow-mapping.test.ts.)
+ const def = stepwiseDef();
+ const { nodes, edges } = irToFlow(def);
+ const columns = def.ir.version === "v2" ? def.ir.columns : [];
+
+ // Simulate the edge inspector toggling the review→exec edge to rework.
+ const reworked = edges.map((e) =>
+ e.source.endsWith("::review") && e.target.endsWith("::exec")
+ ? { ...e, data: { ...(e.data ?? {}), condition: "outcome:approve", kind: "rework" } }
+ : e,
+ );
+ const { ir: out } = flowToIr("Stepwise", nodes, reworked, columns);
+ const foreach = out.nodes.find((n) => n.kind === "foreach")!;
+ const template = foreach.config!.template as { edges: { condition?: string; kind?: string }[] };
+ expect(template.edges.find((e) => e.condition === "outcome:approve")?.kind).toBe("rework");
+
+ // Removing rework (toggle off) drops the kind on round-trip.
+ const cleared = edges.map((e) =>
+ e.source.endsWith("::review") && e.target.endsWith("::exec")
+ ? { ...e, data: { ...(e.data ?? {}), condition: "outcome:approve", kind: undefined } }
+ : e,
+ );
+ const { ir: out2 } = flowToIr("Stepwise", nodes, cleared, columns);
+ const fe2 = out2.nodes.find((n) => n.kind === "foreach")!;
+ const tpl2 = fe2.config!.template as { edges: { condition?: string; kind?: string }[] };
+ expect(tpl2.edges.find((e) => e.condition === "outcome:approve")?.kind).toBeUndefined();
+ });
+
+ it("surfaces a parseWorkflowIr validation error inline at save (unrouted approve edge)", async () => {
+ const addToast = vi.fn();
+ vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
+ vi.mocked(updateWorkflow).mockRejectedValue(
+ new Error("step-review node 'review' must route outcome:revise"),
+ );
+ render( {}} addToast={addToast} />);
+ await screen.findByText("Save");
+ await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
+ fireEvent.click(screen.getByText("Save").closest("button")!);
+ await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
+ // Validation banner renders the server error inline.
+ await waitFor(() =>
+ expect(screen.getByText(/must route outcome:revise/i)).toBeInTheDocument(),
+ );
+ expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/must route outcome:revise/i), "error");
+ });
+
+ it("edits parse-steps artifact (from declared artifacts) and parser", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
+ render( {}} addToast={() => {}} />);
+ const parseNode = await screen.findByTestId("wf-node-parse-steps");
+ fireEvent.click(parseNode);
+ const artifactSel = (await screen.findByText("Artifact")).parentElement!.querySelector("select")! as HTMLSelectElement;
+ // Sourced from the workflow's declared artifacts.
+ expect(artifactSel.value).toBe("PROMPT.md");
+ const parserSel = screen.getByText("Parser").parentElement!.querySelector("select")! as HTMLSelectElement;
+ fireEvent.change(parserSel, { target: { value: "json-steps" } });
+ expect(parserSel.value).toBe("json-steps");
+ });
+
+ it("edits a code node source and timeout", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
+ render( {}} addToast={() => {}} />);
+ await screen.findByText("Save");
+ fireEvent.click(screen.getByText("Code").closest("button")!);
+ const source = (await screen.findByText("Source (TypeScript)")).parentElement!.querySelector("textarea")! as HTMLTextAreaElement;
+ fireEvent.change(source, { target: { value: "export default async()=>({outcome:'success'})" } });
+ expect(source.value).toContain("outcome:'success'");
+ const timeout = screen.getByText("Timeout (ms)").parentElement!.querySelector("input")! as HTMLInputElement;
+ fireEvent.change(timeout, { target: { value: "12000" } });
+ expect(timeout.value).toBe("12000");
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts
index f496ba0002..12c5fe4b5e 100644
--- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts
+++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts
@@ -11,6 +11,9 @@ import {
isColumnBandNode,
validateColumnsClient,
unplacedNodeIds,
+ foreachChildFlowId,
+ templateNodeIdFromChild,
+ shortConditionLabel,
COLUMN_BAND_HEIGHT,
} from "../workflow-flow-mapping";
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
@@ -302,3 +305,135 @@ describe("workflow-flow-mapping validation helpers", () => {
expect(COLUMN_BAND_HEIGHT).toBeGreaterThan(0);
});
});
+
+// ── U8: step-inversion round-trip (foreach template, rework edges) ───────────
+
+describe("workflow-flow-mapping foreach + rework round-trip", () => {
+ const ir: WorkflowDefinition["ir"] = {
+ version: "v2",
+ name: "stepwise",
+ columns: [
+ { id: "plan", name: "Plan", traits: [] },
+ { id: "in-progress", name: "In progress", traits: [] },
+ { id: "done", name: "Done", traits: [] },
+ ],
+ nodes: [
+ { id: "start", kind: "start", column: "plan" },
+ { id: "parse", kind: "parse-steps", column: "plan", config: { artifact: "PROMPT.md", parser: "step-headings" } },
+ {
+ id: "loop",
+ kind: "foreach",
+ column: "in-progress",
+ config: {
+ source: "task-steps",
+ mode: "sequential",
+ isolation: "shared",
+ maxReworkCycles: 3,
+ template: {
+ nodes: [
+ { id: "exec", kind: "prompt", config: { seam: "step-execute", prompt: "do step" } },
+ { id: "review", kind: "step-review", config: { type: "code" } },
+ ],
+ edges: [
+ { from: "exec", to: "review", condition: "success" },
+ { from: "review", to: "exec", condition: "outcome:revise", kind: "rework" },
+ ],
+ },
+ },
+ },
+ { id: "end", kind: "end", column: "done" },
+ ],
+ edges: [
+ { from: "start", to: "parse", condition: "success" },
+ { from: "parse", to: "loop", condition: "success" },
+ { from: "loop", to: "end", condition: "success" },
+ ],
+ };
+
+ it("round-trips foreach template (children partitioned by parentId) losslessly", () => {
+ const def = makeDef(ir);
+ const { nodes, edges } = irToFlow(def);
+ const columns = columnsOf(def);
+
+ // The foreach group + its two template children render as parented nodes.
+ const group = nodes.find((n) => n.id === "loop");
+ expect(group?.type).toBe("foreach");
+ const children = nodes.filter((n) => n.parentId === "loop");
+ expect(children.map((c) => c.id).sort()).toEqual(
+ [foreachChildFlowId("loop", "exec"), foreachChildFlowId("loop", "review")].sort(),
+ );
+ // Template edges (incl. the rework edge) live inside the group's id-scope.
+ const reworkFlowEdge = edges.find((e) => e.data?.kind === "rework");
+ expect(reworkFlowEdge).toBeTruthy();
+ expect(reworkFlowEdge?.source).toBe(foreachChildFlowId("loop", "review"));
+
+ const { ir: out } = flowToIr("stepwise", nodes, edges, columns);
+ if (out.version !== "v2") throw new Error("expected v2");
+ const loop = out.nodes.find((n) => n.id === "loop");
+ expect(loop?.kind).toBe("foreach");
+ const cfg = loop?.config as Record;
+ expect(cfg.source).toBe("task-steps");
+ expect(cfg.mode).toBe("sequential");
+ expect(cfg.maxReworkCycles).toBe(3);
+ const template = cfg.template as { nodes: unknown[]; edges: { from: string; to: string; condition?: string; kind?: string }[] };
+ // Template node ids are template-local (de-namespaced), not flow ids.
+ expect((template.nodes as { id: string }[]).map((n) => n.id).sort()).toEqual(["exec", "review"]);
+ // The rework edge survives with its kind and outcome condition.
+ const rework = template.edges.find((e) => e.kind === "rework");
+ expect(rework).toEqual({ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" });
+ // The plain success edge has no kind.
+ const success = template.edges.find((e) => e.condition === "success");
+ expect(success?.kind).toBeUndefined();
+ // Top-level edges exclude the intra-template ones.
+ expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual([
+ "start->parse",
+ "parse->loop",
+ "loop->end",
+ ]);
+ // parse-steps config preserved.
+ const parse = out.nodes.find((n) => n.id === "parse");
+ expect(parse?.config).toMatchObject({ artifact: "PROMPT.md", parser: "step-headings" });
+ });
+
+ it("round-trips a code node config (source + timeoutMs)", () => {
+ const codeIr: WorkflowDefinition["ir"] = {
+ version: "v1",
+ name: "wf",
+ nodes: [
+ { id: "start", kind: "start" },
+ { id: "c1", kind: "code", config: { source: "export default async()=>({})", timeoutMs: 5000 } },
+ { id: "end", kind: "end" },
+ ],
+ edges: [
+ { from: "start", to: "c1", condition: "success" },
+ { from: "c1", to: "end", condition: "success" },
+ ],
+ };
+ const { nodes, edges } = irToFlow(makeDef(codeIr));
+ const { ir: out } = flowToIr("wf", nodes, edges);
+ const c1 = out.nodes.find((n) => n.id === "c1");
+ expect(c1?.kind).toBe("code");
+ expect(c1?.config).toMatchObject({ source: "export default async()=>({})", timeoutMs: 5000 });
+ });
+
+ it("child id namespacing helpers are inverse", () => {
+ const fid = foreachChildFlowId("loop", "exec");
+ expect(templateNodeIdFromChild("loop", fid)).toBe("exec");
+ // A non-namespaced id passes through unchanged.
+ expect(templateNodeIdFromChild("loop", "other")).toBe("other");
+ });
+
+ it("shortens outcome: edge labels", () => {
+ expect(shortConditionLabel("outcome:approve")).toBe("approve");
+ expect(shortConditionLabel("success")).toBe("success");
+ });
+
+ it("does not flag foreach template children as unplaced", () => {
+ const def = makeDef(ir);
+ const { nodes } = irToFlow(def);
+ const columns = columnsOf(def);
+ const ids = unplacedNodeIds(nodes, columns);
+ expect(ids).not.toContain(foreachChildFlowId("loop", "exec"));
+ expect(ids).not.toContain(foreachChildFlowId("loop", "review"));
+ });
+});
diff --git a/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx b/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx
index 2f83e337bb..98f7995185 100644
--- a/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx
+++ b/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx
@@ -1,8 +1,12 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
-import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle } from "lucide-react";
+import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react";
/** Node kinds the editor can render. "merge" is the pre/post-merge seam marker.
- * v2 adds "hold" (passive dwell), "split"/"join" (parallel fan-out). */
+ * v2 adds "hold" (passive dwell), "split"/"join" (parallel fan-out). The
+ * step-inversion additions (KTD-3/4/12/15): "foreach" (runtime-expanding
+ * per-step template region, rendered as a React Flow group), "step-review"
+ * (per-step review verdicts as outcome edges), "parse-steps" (graph-native
+ * step-list parsing), and "code" (sandboxed TypeScript). */
export type WorkflowEditorNodeKind =
| "start"
| "end"
@@ -12,7 +16,11 @@ export type WorkflowEditorNodeKind =
| "merge"
| "hold"
| "split"
- | "join";
+ | "join"
+ | "foreach"
+ | "step-review"
+ | "parse-steps"
+ | "code";
export interface WorkflowFlowNodeData {
kind: WorkflowEditorNodeKind;
@@ -26,6 +34,11 @@ export interface WorkflowFlowNodeData {
/** When true, render the shared error-state badge on the node (unplaced node
* or seam-in-branch). Set by the editor from validation. */
errorBadge?: string;
+ /** foreach group only: true when it has no template children (deletion can
+ * empty it even though the palette auto-populates one). */
+ templateEmpty?: boolean;
+ /** foreach group only: the localized empty-state hint string. */
+ emptyHint?: string;
[key: string]: unknown;
}
@@ -39,6 +52,10 @@ const KIND_ICON: Record = {
hold: PauseCircle,
split: Split,
join: Merge,
+ foreach: Repeat,
+ "step-review": ClipboardCheck,
+ "parse-steps": ListChecks,
+ code: Code2,
};
/** Shared error-state component (U10): one component renders both the
@@ -66,9 +83,14 @@ function NodeShell({ data, kind }: { data: WorkflowFlowNodeData; kind: WorkflowE
return typeof m === "string" ? m : "all";
})()
: undefined;
+ // Step-execute seam prompt nodes (only legal inside a foreach template) carry
+ // a distinguishing badge so the template's execute node reads clearly.
+ const seam = kind === "prompt" ? (data.config?.seam as string | undefined) : undefined;
+ const reviewType = kind === "step-review" ? (data.config?.type as string | undefined) : undefined;
+ const parser = kind === "parse-steps" ? (data.config?.parser as string | undefined) : undefined;
return (
{showTarget && }
@@ -79,12 +101,48 @@ function NodeShell({ data, kind }: { data: WorkflowFlowNodeData; kind: WorkflowE
{kind === "gate" && gate }
{release && {release} }
{joinMode && {joinMode} }
+ {seam === "step-execute" && step }
+ {reviewType && {reviewType} }
+ {parser && {parser} }
{data.errorBadge && }
{showSource && }
);
}
+/** A `foreach` node renders as a React Flow group: template nodes are children
+ * (parentId = the group id) laid out inside it. When empty, an empty-state hint
+ * prompts the author to drop a step-execute node in. The mode/isolation config
+ * is summarized in a header badge row. */
+function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
+ const mode = (data.config?.mode as string | undefined) ?? "sequential";
+ const isolation = (data.config?.isolation as string | undefined) ?? (mode === "parallel" ? "worktree" : "shared");
+ const isEmpty = data.templateEmpty === true;
+ return (
+
+
+
+
+
+
+ {data.label || "foreach"}
+ {mode}
+ {isolation}
+
+ {isEmpty && (
+
+ {data.emptyHint || "Drag a step-execute node here"}
+
+ )}
+ {data.errorBadge &&
}
+
+
+ );
+}
+
export const workflowNodeTypes = {
start: ({ data }: NodeProps) => ,
end: ({ data }: NodeProps) => ,
@@ -95,4 +153,8 @@ export const workflowNodeTypes = {
hold: ({ data }: NodeProps) => ,
split: ({ data }: NodeProps) => ,
join: ({ data }: NodeProps) => ,
+ foreach: ({ data }: NodeProps) => ,
+ "step-review": ({ data }: NodeProps) => ,
+ "parse-steps": ({ data }: NodeProps) => ,
+ code: ({ data }: NodeProps) => ,
};
diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts
index 2aaa22b8f0..adc72c49cd 100644
--- a/packages/dashboard/app/components/workflow-flow-mapping.ts
+++ b/packages/dashboard/app/components/workflow-flow-mapping.ts
@@ -3,10 +3,50 @@ import type {
WorkflowIr,
WorkflowIrV2,
WorkflowIrColumn,
+ WorkflowIrNode,
+ WorkflowIrEdge,
WorkflowDefinition,
} from "@fusion/core";
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
+/** Local mirror of @fusion/core's WorkflowForeachConfig (KTD-3). The core index
+ * barrel does not re-export it, and the dashboard build aliases @fusion/core to
+ * a types-only entry, so we describe just the shape this mapping needs. */
+interface WorkflowForeachConfig {
+ source: "task-steps";
+ maxReworkCycles?: number;
+ mode?: "sequential" | "parallel";
+ concurrency?: number;
+ isolation?: "shared" | "worktree";
+ template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
+}
+
+// ── foreach template region (KTD-3, U8) ──────────────────────────────────────
+//
+// A `foreach` node is authored inline as a React Flow group node whose template
+// subgraph nodes are children with `parentId` set to the group id. To keep child
+// flow-node ids globally unique while preserving the *template-local* ids that
+// the IR's `config.template` stores, child flow ids are namespaced as
+// `::`; flowToIr strips the prefix back out when it
+// reassembles the template. Geometry for the group + auto-layout for template
+// nodes lacking persisted layout data.
+export const FOREACH_GROUP_WIDTH = 520;
+export const FOREACH_GROUP_HEIGHT = 200;
+export const FOREACH_CHILD_X = 30;
+export const FOREACH_CHILD_Y = 56;
+export const FOREACH_CHILD_STEP_X = 170;
+
+const FOREACH_CHILD_SEP = "::";
+/** Compose a globally-unique flow-node id for a template child. */
+export function foreachChildFlowId(groupId: string, templateNodeId: string): string {
+ return `${groupId}${FOREACH_CHILD_SEP}${templateNodeId}`;
+}
+/** Recover the template-local node id from a namespaced child flow id. */
+export function templateNodeIdFromChild(groupId: string, childFlowId: string): string {
+ const prefix = `${groupId}${FOREACH_CHILD_SEP}`;
+ return childFlowId.startsWith(prefix) ? childFlowId.slice(prefix.length) : childFlowId;
+}
+
/** Layout geometry for column swimlane bands. Bands stack vertically; each band
* is full-width and a node's `column` is derived by hit-testing the node's y
* against the band rows (position-based, so the editor's existing absolute
@@ -85,14 +125,52 @@ export function columnsToBandNodes(columns: WorkflowIrColumn[]): FlowNode | undefined;
+ if (!cfg || !cfg.template) return undefined;
+ return cfg as WorkflowForeachConfig;
+}
+
+/** Build a React Flow edge from an IR edge. Rework edges (KTD-5) carry kind so
+ * the editor renders them dashed in the accent color. */
+function irEdgeToFlow(edge: WorkflowIrEdge, index: number, idScope = ""): FlowEdge {
+ const condition = edge.condition ?? "success";
+ const isRework = edge.kind === "rework";
+ return {
+ id: `e-${idScope}${edge.from}-${edge.to}-${index}`,
+ source: idScope ? `${idScope}${edge.from}` : edge.from,
+ target: idScope ? `${idScope}${edge.to}` : edge.to,
+ label: isRework ? `${shortConditionLabel(condition)} (rework)` : shortConditionLabel(condition),
+ data: { condition, kind: isRework ? "rework" : undefined },
+ type: isRework ? "step" : undefined,
+ animated: isRework,
+ className: isRework ? "wf-edge-rework" : undefined,
+ markerEnd: undefined,
+ };
+}
+
+/** Short display label for an edge condition. `outcome:` conditions
+ * render as the verdict alone (KTD-4); everything else verbatim. */
+export function shortConditionLabel(condition: string): string {
+ if (condition.startsWith("outcome:")) return condition.slice("outcome:".length);
+ return condition;
+}
+
/** Build React Flow nodes/edges from a stored workflow definition. v2 columns
- * render as swimlane band group nodes; step nodes carry their `column`. */
+ * render as swimlane band group nodes; step nodes carry their `column`. A
+ * `foreach` node renders as a group whose template subgraph nodes are children
+ * (parentId = the group id). */
export function irToFlow(def: WorkflowDefinition): {
nodes: FlowNode[];
edges: FlowEdge[];
} {
const columns = isV2(def.ir) ? def.ir.columns : [];
const bandNodes = columnsToBandNodes(columns);
+ const childNodes: FlowNode[] = [];
+ const childEdges: FlowEdge[] = [];
const stepNodes = def.ir.nodes.map((node, index): FlowNode => {
const pos = def.layout?.[node.id];
@@ -102,6 +180,51 @@ export function irToFlow(def: WorkflowDefinition): {
// Default placement seeds the node inside its column band when no persisted
// layout exists; otherwise we honor the saved absolute position.
const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120;
+
+ const foreachCfg = foreachConfigOf(node);
+ if (foreachCfg) {
+ const template = foreachCfg.template;
+ // Render template nodes as children of this group (parentId = group id).
+ template.nodes.forEach((inner, innerIdx) => {
+ const childFlowId = foreachChildFlowId(node.id, inner.id);
+ // Template layout lives under namespaced keys; auto-layout otherwise.
+ const childPos =
+ def.layout?.[childFlowId] ?? {
+ x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X,
+ y: FOREACH_CHILD_Y,
+ };
+ const innerKind = editorKind(inner);
+ childNodes.push({
+ id: childFlowId,
+ type: innerKind,
+ position: childPos,
+ parentId: node.id,
+ extent: "parent",
+ data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } },
+ deletable: true,
+ });
+ });
+ template.edges.forEach((edge, eIdx) => {
+ childEdges.push(irEdgeToFlow(edge, eIdx, `${node.id}${FOREACH_CHILD_SEP}`));
+ });
+ // Strip the template off the group node's own config (children carry it).
+ const { template: _t, ...restCfg } = (node.config ?? {}) as Record;
+ return {
+ id: node.id,
+ type: "foreach",
+ position: pos ?? { x: 80 + index * 180, y: fallbackY },
+ data: {
+ kind: "foreach",
+ label: nodeLabel(node),
+ config: { ...restCfg },
+ column,
+ templateEmpty: template.nodes.length === 0,
+ },
+ style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
+ deletable: true,
+ };
+ }
+
return {
id: node.id,
type: kind,
@@ -116,18 +239,10 @@ export function irToFlow(def: WorkflowDefinition): {
};
});
- const edges = def.ir.edges.map((edge, index): FlowEdge => {
- const condition = edge.condition ?? "success";
- return {
- id: `e-${edge.from}-${edge.to}-${index}`,
- source: edge.from,
- target: edge.to,
- label: condition,
- data: { condition },
- };
- });
+ const edges = def.ir.edges.map((edge, index): FlowEdge => irEdgeToFlow(edge, index));
- return { nodes: [...bandNodes, ...stepNodes], edges };
+ // Group nodes must precede their children in the array for React Flow.
+ return { nodes: [...bandNodes, ...stepNodes, ...childNodes], edges: [...edges, ...childEdges] };
}
/** Sanitize a node config, applying the v1 round-trip name rules. */
@@ -158,35 +273,77 @@ export function flowToIr(
edges: FlowEdge[],
columns?: WorkflowIrColumn[],
): { ir: WorkflowIr; layout: Record } {
- const stepNodes = nodes.filter((n) => !isColumnBandNode(n.id) && n.type !== "group");
+ const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
+ // Partition by parentId: foreach group children reassemble into that group's
+ // config.template; everything else (no parentId) is top-level. (Column band
+ // group nodes are already excluded above.)
+ const topNodes = realNodes.filter((n) => !n.parentId);
+ const childrenByGroup = new Map[]>();
+ for (const n of realNodes) {
+ if (n.parentId) {
+ const arr = childrenByGroup.get(n.parentId) ?? [];
+ arr.push(n);
+ childrenByGroup.set(n.parentId, arr);
+ }
+ }
+ const groupIds = new Set(topNodes.filter((n) => n.data.kind === "foreach").map((n) => n.id));
const v2 = Array.isArray(columns) && columns.length > 0;
+ const layout: Record = {};
- const irNodes: WorkflowIr["nodes"] = stepNodes.map((node) => {
+ /** Project one flow node (top-level or template child) into an IR node. */
+ function toIrNode(node: FlowNode, localId: string): WorkflowIrNode {
const data = node.data;
const config = nodeConfig(node);
- // Derive column placement from the node's y position relative to the bands.
- const column = v2 ? data.column ?? columnForY(node.position.y, columns!) : undefined;
if (data.kind === "merge") {
- const cfg = { ...(config ?? {}), seam: "merge" };
- return { id: node.id, kind: "prompt" as const, ...(column ? { column } : {}), config: cfg };
+ return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
+ }
+ if (data.kind === "foreach") {
+ // Reassemble the template from this group's children.
+ const children = childrenByGroup.get(node.id) ?? [];
+ const templateNodes: WorkflowIrNode[] = children.map((c) => {
+ const innerId = templateNodeIdFromChild(node.id, c.id);
+ layout[c.id] = { x: Math.round(c.position.x), y: Math.round(c.position.y) };
+ return toIrNode(c, innerId);
+ });
+ const childIdSet = new Set(children.map((c) => c.id));
+ const templateEdges: WorkflowIrEdge[] = edges
+ .filter((e) => childIdSet.has(e.source) && childIdSet.has(e.target))
+ .map((e) => flowEdgeToIr(e, node.id));
+ const baseCfg = (config ?? {}) as Record;
+ return {
+ id: localId,
+ kind: "foreach",
+ config: { ...baseCfg, template: { nodes: templateNodes, edges: templateEdges } },
+ };
}
return {
- id: node.id,
- kind: data.kind,
- ...(column ? { column } : {}),
+ id: localId,
+ kind: data.kind as WorkflowIrNode["kind"],
config: config && Object.keys(config).length ? config : undefined,
};
+ }
+
+ const irNodes: WorkflowIr["nodes"] = topNodes.map((node) => {
+ const column = v2 ? node.data.column ?? columnForY(node.position.y, columns!) : undefined;
+ const base = toIrNode(node, node.id);
+ layout[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) };
+ return column ? { ...base, column } : base;
});
- const irEdges: WorkflowIr["edges"] = edges.map((edge) => {
- const condition = (edge.data?.condition as string | undefined) ?? "success";
- return { from: edge.source, to: edge.target, condition };
- });
+ // Top-level edges: exclude any edge that lives entirely inside a foreach
+ // template (both endpoints are children of the same group) — those are folded
+ // into the group's template above.
+ const childIdToGroup = new Map();
+ for (const [gid, kids] of childrenByGroup) for (const k of kids) childIdToGroup.set(k.id, gid);
+ const irEdges: WorkflowIr["edges"] = edges
+ .filter((e) => {
+ const sg = childIdToGroup.get(e.source);
+ const tg = childIdToGroup.get(e.target);
+ return !(sg && tg && sg === tg);
+ })
+ .map((e) => flowEdgeToIr(e));
- const layout = stepNodes.reduce>((acc, node) => {
- acc[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) };
- return acc;
- }, {});
+ void groupIds;
if (v2) {
const ir: WorkflowIrV2 = {
@@ -202,6 +359,17 @@ export function flowToIr(
return { ir: { version: "v1", name, nodes: irNodes, edges: irEdges }, layout };
}
+/** Project a React Flow edge into an IR edge. Rework edges carry `kind`. When
+ * `groupId` is given the endpoints are de-namespaced back to template-local
+ * ids. */
+function flowEdgeToIr(edge: FlowEdge, groupId?: string): WorkflowIrEdge {
+ const condition = (edge.data?.condition as string | undefined) ?? "success";
+ const isRework = (edge.data?.kind as string | undefined) === "rework";
+ const from = groupId ? templateNodeIdFromChild(groupId, edge.source) : edge.source;
+ const to = groupId ? templateNodeIdFromChild(groupId, edge.target) : edge.target;
+ return { from, to, condition, ...(isRework ? { kind: "rework" as const } : {}) };
+}
+
// ── Client-side validation (U10) ─────────────────────────────────────────────
//
// The server's parseWorkflowIr (run on PATCH) is the authority for structural
@@ -321,6 +489,8 @@ export function unplacedNodeIds(
const ids: string[] = [];
for (const node of nodes) {
if (isColumnBandNode(node.id) || node.type === "group") continue;
+ // foreach template children are placed by their parent group, not a column.
+ if (node.parentId) continue;
if (node.data.kind === "start" || node.data.kind === "end") continue;
// A node is placed if it carries a valid column id, or if its y falls
// strictly within a band's extent. A node parked outside every band with
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index fd8f4aac03..452b4e0335 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -6717,9 +6717,28 @@
},
"workflowNodes": {
"advisory": "Advisory",
+ "codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
+ "codeSource": "Source (TypeScript)",
+ "codeTimeout": "Timeout (ms)",
+ "edgeConditionLabel": "Condition: {{condition}}",
+ "edgeInspector": "Edge",
+ "edgeNoVerdict": "— success (no verdict) —",
+ "edgeRework": "Rework edge (loop back, bounded)",
+ "edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
+ "edgeVerdict": "Review verdict",
"failureCollect": "Collect (wait for all)",
"failureFailFast": "Fail-fast (cancel siblings)",
"failurePolicy": "On branch failure",
+ "foreachConcurrency": "Concurrency",
+ "foreachEmptyHint": "Drag a step-execute node here",
+ "foreachIsolation": "Isolation",
+ "foreachMaxRework": "Max rework cycles",
+ "foreachMode": "Mode",
+ "foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
+ "foreachParallel": "Parallel",
+ "foreachSequential": "Sequential",
+ "foreachShared": "Shared worktree",
+ "foreachWorktree": "Per-step worktree",
"gateBlocks": "Gate (blocks)",
"gateMode": "Gate mode",
"joinAll": "All branches",
@@ -6727,6 +6746,8 @@
"joinMode": "Join mode",
"joinQuorum": "Quorum (n)",
"mergeBoundaryNote": "Steps before this marker run pre-merge; steps after run post-merge.",
+ "parseArtifact": "Artifact",
+ "parseParser": "Parser",
"quorumN": "Quorum count (n)",
"releaseCapacity": "Downstream capacity",
"releaseCondition": "Release condition",
@@ -6734,7 +6755,13 @@
"releaseExternal": "External event",
"releaseManual": "Manual promote",
"releaseTimer": "Timer",
- "splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch."
+ "reviewCode": "Code review",
+ "reviewModel": "Review model (optional)",
+ "reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
+ "reviewPlan": "Plan review",
+ "reviewType": "Review type",
+ "splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
+ "stepExecuteLabel": "Step execute"
},
"workflows": {
"duplicateToCustomize": "Duplicate to customize",
diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json
index bf61429fd9..945de02bbd 100644
--- a/packages/i18n/locales/es/app.json
+++ b/packages/i18n/locales/es/app.json
@@ -6717,9 +6717,28 @@
},
"workflowNodes": {
"advisory": "",
+ "codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
+ "codeSource": "Source (TypeScript)",
+ "codeTimeout": "Timeout (ms)",
+ "edgeConditionLabel": "Condition: {{condition}}",
+ "edgeInspector": "Edge",
+ "edgeNoVerdict": "— success (no verdict) —",
+ "edgeRework": "Rework edge (loop back, bounded)",
+ "edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
+ "edgeVerdict": "Review verdict",
"failureCollect": "",
"failureFailFast": "",
"failurePolicy": "",
+ "foreachConcurrency": "Concurrency",
+ "foreachEmptyHint": "Drag a step-execute node here",
+ "foreachIsolation": "Isolation",
+ "foreachMaxRework": "Max rework cycles",
+ "foreachMode": "Mode",
+ "foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
+ "foreachParallel": "Parallel",
+ "foreachSequential": "Sequential",
+ "foreachShared": "Shared worktree",
+ "foreachWorktree": "Per-step worktree",
"gateBlocks": "",
"gateMode": "",
"joinAll": "",
@@ -6727,6 +6746,8 @@
"joinMode": "",
"joinQuorum": "",
"mergeBoundaryNote": "",
+ "parseArtifact": "Artifact",
+ "parseParser": "Parser",
"quorumN": "",
"releaseCapacity": "",
"releaseCondition": "",
@@ -6734,7 +6755,13 @@
"releaseExternal": "",
"releaseManual": "",
"releaseTimer": "",
- "splitNote": ""
+ "reviewCode": "Code review",
+ "reviewModel": "Review model (optional)",
+ "reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
+ "reviewPlan": "Plan review",
+ "reviewType": "Review type",
+ "splitNote": "",
+ "stepExecuteLabel": "Step execute"
},
"workflows": {
"duplicateToCustomize": "",
diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json
index 03c1ac65e2..016b6d09b3 100644
--- a/packages/i18n/locales/fr/app.json
+++ b/packages/i18n/locales/fr/app.json
@@ -6717,9 +6717,28 @@
},
"workflowNodes": {
"advisory": "",
+ "codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
+ "codeSource": "Source (TypeScript)",
+ "codeTimeout": "Timeout (ms)",
+ "edgeConditionLabel": "Condition: {{condition}}",
+ "edgeInspector": "Edge",
+ "edgeNoVerdict": "— success (no verdict) —",
+ "edgeRework": "Rework edge (loop back, bounded)",
+ "edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
+ "edgeVerdict": "Review verdict",
"failureCollect": "",
"failureFailFast": "",
"failurePolicy": "",
+ "foreachConcurrency": "Concurrency",
+ "foreachEmptyHint": "Drag a step-execute node here",
+ "foreachIsolation": "Isolation",
+ "foreachMaxRework": "Max rework cycles",
+ "foreachMode": "Mode",
+ "foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
+ "foreachParallel": "Parallel",
+ "foreachSequential": "Sequential",
+ "foreachShared": "Shared worktree",
+ "foreachWorktree": "Per-step worktree",
"gateBlocks": "",
"gateMode": "",
"joinAll": "",
@@ -6727,6 +6746,8 @@
"joinMode": "",
"joinQuorum": "",
"mergeBoundaryNote": "",
+ "parseArtifact": "Artifact",
+ "parseParser": "Parser",
"quorumN": "",
"releaseCapacity": "",
"releaseCondition": "",
@@ -6734,7 +6755,13 @@
"releaseExternal": "",
"releaseManual": "",
"releaseTimer": "",
- "splitNote": ""
+ "reviewCode": "Code review",
+ "reviewModel": "Review model (optional)",
+ "reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
+ "reviewPlan": "Plan review",
+ "reviewType": "Review type",
+ "splitNote": "",
+ "stepExecuteLabel": "Step execute"
},
"workflows": {
"duplicateToCustomize": "",
diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json
index 8c337823f0..23841f912d 100644
--- a/packages/i18n/locales/ko/app.json
+++ b/packages/i18n/locales/ko/app.json
@@ -6717,9 +6717,28 @@
},
"workflowNodes": {
"advisory": "",
+ "codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
+ "codeSource": "Source (TypeScript)",
+ "codeTimeout": "Timeout (ms)",
+ "edgeConditionLabel": "Condition: {{condition}}",
+ "edgeInspector": "Edge",
+ "edgeNoVerdict": "— success (no verdict) —",
+ "edgeRework": "Rework edge (loop back, bounded)",
+ "edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
+ "edgeVerdict": "Review verdict",
"failureCollect": "",
"failureFailFast": "",
"failurePolicy": "",
+ "foreachConcurrency": "Concurrency",
+ "foreachEmptyHint": "Drag a step-execute node here",
+ "foreachIsolation": "Isolation",
+ "foreachMaxRework": "Max rework cycles",
+ "foreachMode": "Mode",
+ "foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
+ "foreachParallel": "Parallel",
+ "foreachSequential": "Sequential",
+ "foreachShared": "Shared worktree",
+ "foreachWorktree": "Per-step worktree",
"gateBlocks": "",
"gateMode": "",
"joinAll": "",
@@ -6727,6 +6746,8 @@
"joinMode": "",
"joinQuorum": "",
"mergeBoundaryNote": "",
+ "parseArtifact": "Artifact",
+ "parseParser": "Parser",
"quorumN": "",
"releaseCapacity": "",
"releaseCondition": "",
@@ -6734,7 +6755,13 @@
"releaseExternal": "",
"releaseManual": "",
"releaseTimer": "",
- "splitNote": ""
+ "reviewCode": "Code review",
+ "reviewModel": "Review model (optional)",
+ "reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
+ "reviewPlan": "Plan review",
+ "reviewType": "Review type",
+ "splitNote": "",
+ "stepExecuteLabel": "Step execute"
},
"workflows": {
"duplicateToCustomize": "",
diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json
index 9b5904cb9a..d99c554946 100644
--- a/packages/i18n/locales/zh-CN/app.json
+++ b/packages/i18n/locales/zh-CN/app.json
@@ -6717,9 +6717,28 @@
},
"workflowNodes": {
"advisory": "",
+ "codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
+ "codeSource": "Source (TypeScript)",
+ "codeTimeout": "Timeout (ms)",
+ "edgeConditionLabel": "Condition: {{condition}}",
+ "edgeInspector": "Edge",
+ "edgeNoVerdict": "— success (no verdict) —",
+ "edgeRework": "Rework edge (loop back, bounded)",
+ "edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
+ "edgeVerdict": "Review verdict",
"failureCollect": "",
"failureFailFast": "",
"failurePolicy": "",
+ "foreachConcurrency": "Concurrency",
+ "foreachEmptyHint": "Drag a step-execute node here",
+ "foreachIsolation": "Isolation",
+ "foreachMaxRework": "Max rework cycles",
+ "foreachMode": "Mode",
+ "foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
+ "foreachParallel": "Parallel",
+ "foreachSequential": "Sequential",
+ "foreachShared": "Shared worktree",
+ "foreachWorktree": "Per-step worktree",
"gateBlocks": "",
"gateMode": "",
"joinAll": "",
@@ -6727,6 +6746,8 @@
"joinMode": "",
"joinQuorum": "",
"mergeBoundaryNote": "",
+ "parseArtifact": "Artifact",
+ "parseParser": "Parser",
"quorumN": "",
"releaseCapacity": "",
"releaseCondition": "",
@@ -6734,7 +6755,13 @@
"releaseExternal": "",
"releaseManual": "",
"releaseTimer": "",
- "splitNote": ""
+ "reviewCode": "Code review",
+ "reviewModel": "Review model (optional)",
+ "reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
+ "reviewPlan": "Plan review",
+ "reviewType": "Review type",
+ "splitNote": "",
+ "stepExecuteLabel": "Step execute"
},
"workflows": {
"duplicateToCustomize": "",
diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json
index 984e8699cd..9f675f4977 100644
--- a/packages/i18n/locales/zh-TW/app.json
+++ b/packages/i18n/locales/zh-TW/app.json
@@ -6717,9 +6717,28 @@
},
"workflowNodes": {
"advisory": "",
+ "codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
+ "codeSource": "Source (TypeScript)",
+ "codeTimeout": "Timeout (ms)",
+ "edgeConditionLabel": "Condition: {{condition}}",
+ "edgeInspector": "Edge",
+ "edgeNoVerdict": "— success (no verdict) —",
+ "edgeRework": "Rework edge (loop back, bounded)",
+ "edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
+ "edgeVerdict": "Review verdict",
"failureCollect": "",
"failureFailFast": "",
"failurePolicy": "",
+ "foreachConcurrency": "Concurrency",
+ "foreachEmptyHint": "Drag a step-execute node here",
+ "foreachIsolation": "Isolation",
+ "foreachMaxRework": "Max rework cycles",
+ "foreachMode": "Mode",
+ "foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
+ "foreachParallel": "Parallel",
+ "foreachSequential": "Sequential",
+ "foreachShared": "Shared worktree",
+ "foreachWorktree": "Per-step worktree",
"gateBlocks": "",
"gateMode": "",
"joinAll": "",
@@ -6727,6 +6746,8 @@
"joinMode": "",
"joinQuorum": "",
"mergeBoundaryNote": "",
+ "parseArtifact": "Artifact",
+ "parseParser": "Parser",
"quorumN": "",
"releaseCapacity": "",
"releaseCondition": "",
@@ -6734,7 +6755,13 @@
"releaseExternal": "",
"releaseManual": "",
"releaseTimer": "",
- "splitNote": ""
+ "reviewCode": "Code review",
+ "reviewModel": "Review model (optional)",
+ "reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
+ "reviewPlan": "Plan review",
+ "reviewType": "Review type",
+ "splitNote": "",
+ "stepExecuteLabel": "Step execute"
},
"workflows": {
"duplicateToCustomize": "",