Merge pull request #1508 from Runfusion/feature/workflow-loop-nodes

Add bounded workflow loop nodes
This commit is contained in:
gsxdsm
2026-06-08 00:33:25 -07:00
committed by GitHub
17 changed files with 1389 additions and 58 deletions

View File

@@ -200,6 +200,7 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
{ 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: "loop", label: "Loop", icon: Repeat, presetConfig: { maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } } },
{ 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: "" } },
@@ -249,6 +250,7 @@ const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEdi
"split",
"join",
"foreach",
"loop",
"step-review",
"parse-steps",
"merge",
@@ -1222,19 +1224,24 @@ function InnerEditor({
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.
if (kind === "foreach" || kind === "loop") {
// Template groups render as React Flow group nodes. Foreach seeds the
// required step-execute seam; loop seeds a regular prompt so authors can
// wire the repeated body immediately. The group node must precede its
// child for React Flow's parent extent to apply.
const childId = foreachChildFlowId(id, newNodeId());
const childLabel =
kind === "foreach"
? t("workflowNodes.stepExecuteLabel", "Step execute")
: t("workflowNodes.loopStepLabel", "Loop step");
const childConfig = kind === "foreach" ? { seam: "step-execute" } : { prompt: "" };
setNodes((ns) => [
...ns,
{
id,
type: "foreach",
type: kind,
position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 },
data: { kind: "foreach", label, config, templateEmpty: false },
data: { kind, label, config, templateEmpty: false },
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
deletable: true,
},
@@ -1246,8 +1253,8 @@ function InnerEditor({
extent: "parent",
data: {
kind: "prompt",
label: t("workflowNodes.stepExecuteLabel", "Step execute"),
config: { seam: "step-execute" },
label: childLabel,
config: childConfig,
},
deletable: true,
},
@@ -1779,21 +1786,25 @@ 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.
// Count current template children per template group so the empty-state hint
// reflects live deletions even though the palette seeds one.
const childCount = new Map<string, number>();
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;
const templateEmpty = n.data.kind === "foreach" ? (childCount.get(n.id) ?? 0) === 0 : undefined;
const isTemplateGroup = n.data.kind === "foreach" || n.data.kind === "loop";
const emptyHint =
n.data.kind === "loop"
? t("workflowNodes.loopEmptyHint", "Drag loop steps here")
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
const templateEmpty = isTemplateGroup ? (childCount.get(n.id) ?? 0) === 0 : undefined;
if (
errorBadge === n.data.errorBadge &&
(n.data.kind !== "foreach" || (templateEmpty === n.data.templateEmpty && n.data.emptyHint === emptyHint))
(!isTemplateGroup || (templateEmpty === n.data.templateEmpty && n.data.emptyHint === emptyHint))
)
return n;
return {
@@ -1801,7 +1812,7 @@ function InnerEditor({
data: {
...n.data,
errorBadge,
...(n.data.kind === "foreach" ? { templateEmpty, emptyHint } : {}),
...(isTemplateGroup ? { templateEmpty, emptyHint } : {}),
},
};
});
@@ -3214,6 +3225,184 @@ function InnerEditor({
})()
) : null}
{selectedNode.data.kind === "loop" ? (
(() => {
const exitWhen =
selectedNode.data.config?.exitWhen &&
typeof selectedNode.data.config.exitWhen === "object"
? (selectedNode.data.config.exitWhen as Record<string, unknown>)
: { type: "output-contains", value: "DONE" };
const exitType = String(exitWhen.type ?? "output-contains");
const exitText =
exitType === "output-matches"
? String(exitWhen.pattern ?? "")
: String(exitWhen.value ?? "");
return (
<>
<label className="wf-field">
<span>{t("workflowNodes.loopExitType", "Exit condition")}</span>
<select
value={exitType}
onChange={(e) => {
const nextType = e.target.value;
updateSelectedData({
config: (prev) => {
const current =
prev.exitWhen && typeof prev.exitWhen === "object"
? (prev.exitWhen as Record<string, unknown>)
: {};
const text =
nextType === "output-matches"
? String(current.pattern ?? current.value ?? "")
: String(current.value ?? current.pattern ?? "");
const nextExitWhen: Record<string, unknown> = {
...current,
type: nextType,
};
delete nextExitWhen.pattern;
delete nextExitWhen.value;
return {
...prev,
exitWhen:
nextType === "output-matches"
? { ...nextExitWhen, pattern: text }
: { ...nextExitWhen, value: text },
};
},
});
}}
>
<option value="output-contains">
{t("workflowNodes.loopOutputContains", "Output contains")}
</option>
<option value="output-matches">
{t("workflowNodes.loopOutputMatches", "Output matches regex")}
</option>
</select>
</label>
<label className="wf-field">
<span>
{exitType === "output-matches"
? t("workflowNodes.loopPattern", "Pattern")
: t("workflowNodes.loopValue", "Value")}
</span>
<input
value={exitText}
placeholder={exitType === "output-matches" ? "DONE|COMPLETE" : "DONE"}
onChange={(e) => {
const value = e.target.value;
updateSelectedData({
config: (prev) => {
const current =
prev.exitWhen && typeof prev.exitWhen === "object"
? (prev.exitWhen as Record<string, unknown>)
: {};
return {
...prev,
exitWhen:
exitType === "output-matches"
? { ...current, type: exitType, pattern: value }
: { ...current, type: exitType, value },
};
},
});
}}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.loopNodeId", "Watch node id (optional)")}</span>
<input
value={String(exitWhen.nodeId ?? "")}
placeholder={t("workflowNodes.loopNodeIdPlaceholder", "Template exit node")}
onChange={(e) => {
const nodeId = e.target.value.trim();
updateSelectedData({
config: (prev) => {
const current =
prev.exitWhen && typeof prev.exitWhen === "object"
? (prev.exitWhen as Record<string, unknown>)
: { type: "output-contains", value: "DONE" };
const next = { ...current };
if (nodeId) next.nodeId = nodeId;
else delete next.nodeId;
return { ...prev, exitWhen: next };
},
});
}}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.loopMaxIterations", "Max iterations")}</span>
<input
type="number"
min={1}
max={50}
placeholder="3"
value={
selectedNode.data.config?.maxIterations != null
? String(selectedNode.data.config.maxIterations)
: ""
}
onChange={(e) => {
const val = e.target.value.trim();
updateSelectedData({
config: (prev) => {
const next = { ...prev };
if (val === "") delete next.maxIterations;
else {
const num = parseInt(val, 10);
if (!isNaN(num)) next.maxIterations = num;
}
return next;
},
});
}}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.loopTimeoutMs", "Timeout (ms)")}</span>
<input
type="number"
min={1}
max={3600000}
placeholder="300000"
value={
selectedNode.data.config?.timeoutMs != null
? String(selectedNode.data.config.timeoutMs)
: ""
}
onChange={(e) => {
const val = e.target.value.trim();
updateSelectedData({
config: (prev) => {
const next = { ...prev };
if (val === "") delete next.timeoutMs;
else {
const num = parseInt(val, 10);
if (!isNaN(num)) next.timeoutMs = num;
}
return next;
},
});
}}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.loopNote",
"Repeats the template until the selected output matches, an iteration limit is reached, or the timeout expires.",
)}
</p>
</>
);
})()
) : null}
{selectedNode.data.kind === "step-review" ? (
<>
<label className="wf-field">