feat(FN-6880): author optional-group container in the node editor (U4)

Render and author optional-group as a third group-container kind beside
foreach/loop: register the node type (OptionalGroupNode) so it renders with a
header + defaultOn badge and parentId template children, treat it as a group
everywhere in workflow-flow-mapping (irToFlow children, flowToIr template
reassembly, cascade-delete, condition-editable), add a palette entry, and an
inspector defaultOn toggle. Includes a node-help entry and round-trip / toggle /
cascade-delete tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 18:29:58 -07:00
parent 896b307a3b
commit be7c23693c
7 changed files with 350 additions and 19 deletions

View File

@@ -16,7 +16,7 @@ import {
} from "@xyflow/react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
@@ -236,6 +236,8 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
// 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" } } },
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group container holds a template subgraph run once when the task enables it (per-task `enabledWorkflowSteps`, seeded from `defaultOn`) and skipped otherwise.
{ kind: "optional-group", label: "Optional group", icon: ToggleRight, presetConfig: { defaultOn: false } },
{ 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: "" } },
@@ -287,6 +289,7 @@ const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEdi
"join",
"foreach",
"loop",
"optional-group",
"step-review",
"parse-steps",
"notify",
@@ -1356,16 +1359,19 @@ function InnerEditor({
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
if (kind === "foreach" || kind === "loop") {
if (kind === "foreach" || kind === "loop" || kind === "optional-group") {
// 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.
// required step-execute seam; loop + optional-group seed a regular prompt
// so authors can wire the body immediately. The group node must precede
// its child for React Flow's parent extent to apply.
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is authored exactly like a foreach/loop region — drop nodes inside; the subgraph runs once when the task enables the group.
const childId = foreachChildFlowId(id, newNodeId());
const childLabel =
kind === "foreach"
? t("workflowNodes.stepExecuteLabel", "Step execute")
: t("workflowNodes.loopStepLabel", "Loop step");
: kind === "optional-group"
? t("workflowNodes.optionalGroupStepLabel", "Optional step")
: t("workflowNodes.loopStepLabel", "Loop step");
const childConfig = kind === "foreach" ? { seam: "step-execute" } : { prompt: "" };
setNodes((ns) => [
...ns,
@@ -1941,11 +1947,14 @@ function InnerEditor({
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 isTemplateGroup = n.data.kind === "foreach" || n.data.kind === "loop";
const isTemplateGroup =
n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group";
const emptyHint =
n.data.kind === "loop"
? t("workflowNodes.loopEmptyHint", "Drag loop steps here")
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
: n.data.kind === "optional-group"
? t("workflowNodes.optionalGroupEmptyHint", "Drag optional 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 &&
@@ -4108,6 +4117,27 @@ function InnerEditor({
})()
) : null}
{/* FNXC:WorkflowOptionalGroup 2026-06-21-11:30: The optional-group inspector exposes the workflow-author `defaultOn` default (whether new tasks enable the group). The group name reuses the shared Name field above; the body is authored by dropping nodes inside, identical to foreach/loop. */}
{selectedNode.data.kind === "optional-group" ? (
<>
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"
data-testid="wf-optional-group-default-on"
checked={Boolean(selectedNode.data.config?.defaultOn)}
onChange={(e) => updateSelectedData({ config: { defaultOn: e.target.checked } })}
/>
<span>{t("workflowNodes.optionalGroupDefaultOn", "Enabled by default for new tasks")}</span>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.optionalGroupNote",
"Runs the steps inside this group once when the task enables it (seeded from this default), and skips them when disabled. Drop the optional steps into the region.",
)}
</p>
</>
) : null}
{selectedNode.data.kind === "step-review" ? (
<>
<label className="wf-field">

View File

@@ -1475,6 +1475,51 @@ function stepwiseDef(): WorkflowDefinition {
};
}
/** A v2 workflow with an optional-group container (defaultOn:false) holding one
* template child, so the editor's optional-group surfaces have something to
* render, toggle, and delete. */
function optionalGroupDef(): WorkflowDefinition {
return {
id: "WF-OPT",
kind: "workflow",
name: "Optional",
description: "",
ir: {
version: "v2",
name: "Optional",
columns: [
{ id: "plan", name: "Plan", traits: [{ trait: "intake" }] },
{ id: "in-progress", name: "In progress", traits: [] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "plan" },
{
id: "opt",
kind: "optional-group",
column: "in-progress",
config: {
defaultOn: false,
name: "Browser verification",
template: {
nodes: [{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } }],
edges: [],
},
},
},
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "opt", condition: "success" },
{ from: "opt", 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);
@@ -1531,6 +1576,80 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
expect(template.nodes[0].config?.seam).toBe("step-execute");
});
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group must be
// authorable like a foreach/loop — added from the palette as a registered group
// container (not react-flow__node-default), filled with nodes, named, toggled
// for defaultOn, and deleted with its children cascaded.
it("adds an optional-group from the palette and round-trips its template on save", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument();
fireEvent.click(screen.getByText("Optional group").closest("button")!);
// Renders via the registered group component (wf-node-optional-group), NOT
// React Flow's default fallback.
await waitFor(() => expect(screen.getByTestId("wf-node-optional-group")).toBeInTheDocument(), { timeout: 5000 });
// No empty hint — the palette seeded an optional step inside.
expect(screen.queryByTestId("wf-optional-group-empty")).not.toBeInTheDocument();
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: { id: string; kind: string; config?: Record<string, unknown> }[] } }).ir;
const group = ir.nodes.find((n) => n.kind === "optional-group");
expect(group).toBeTruthy();
const template = group!.config!.template as { nodes: unknown[] };
expect(template.nodes).toHaveLength(1);
});
it("toggles optional-group defaultOn, marks the editor dirty, and persists on save", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...optionalGroupDef(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
const group = await screen.findByTestId("wf-node-optional-group");
fireEvent.click(group);
const toggle = await screen.findByTestId("wf-optional-group-default-on");
expect((toggle as HTMLInputElement).checked).toBe(false);
fireEvent.click(toggle);
expect((toggle as HTMLInputElement).checked).toBe(true);
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<string, unknown> }[] } }).ir;
const opt = ir.nodes.find((n) => n.kind === "optional-group");
expect(opt!.config!.defaultOn).toBe(true);
});
it("deletes an optional-group and removes its parentId children (no orphans)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const group = await screen.findByTestId("wf-node-optional-group");
// The seeded template child renders as a parented flow node.
await waitFor(() =>
expect(
document.querySelector(`.react-flow__node[data-id="${foreachChildFlowId("opt", "verify")}"]`),
).toBeInTheDocument(),
);
fireEvent.click(group);
fireEvent.click(await screen.findByTestId("wf-delete-node"));
await waitFor(() => expect(screen.queryByTestId("wf-node-optional-group")).not.toBeInTheDocument());
// The template child is gone too (cascade) — no orphaned parentId node.
expect(
document.querySelector(`.react-flow__node[data-id="${foreachChildFlowId("opt", "verify")}"]`),
).not.toBeInTheDocument();
});
it("edits foreach mode/isolation/concurrency/maxReworkCycles inspector fields", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);

View File

@@ -766,6 +766,104 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
expect(template.edges).toEqual([{ from: "try", to: "check", condition: "success" }]);
});
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group's template
// subgraph must round-trip through the editor's parentId-child rendering exactly
// like foreach/loop — irToFlow renders the template as parented children;
// flowToIr reassembles them into config.template, preserving defaultOn/name.
it("round-trips an optional-group template (children partitioned by parentId) losslessly", () => {
const optionalIr: WorkflowDefinition["ir"] = {
version: "v2",
name: "optional",
columns: ir.columns,
nodes: [
{ id: "start", kind: "start", column: "plan" },
{
id: "opt",
kind: "optional-group",
column: "in-progress",
config: {
defaultOn: true,
name: "Browser verification",
template: {
nodes: [
{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } },
{ id: "check", kind: "gate", config: { prompt: "ok?" } },
],
edges: [{ from: "verify", to: "check", condition: "success" }],
},
},
},
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "opt", condition: "success" },
{ from: "opt", to: "end", condition: "success" },
],
};
const def = makeDef(optionalIr);
const { nodes, edges } = irToFlow(def);
const columns = columnsOf(def);
// The optional-group renders via the registered group component (type
// "optional-group", NOT react-flow__node-default) with parented children.
const group = nodes.find((n) => n.id === "opt");
expect(group?.type).toBe("optional-group");
expect(group?.data.kind).toBe("optional-group");
// The group node keeps defaultOn/name; the template is stripped onto children.
expect(group?.data.config?.defaultOn).toBe(true);
expect((group?.data.config as Record<string, unknown>)?.template).toBeUndefined();
const children = nodes.filter((n) => n.parentId === "opt");
expect(children.map((c) => templateNodeIdFromChild("opt", c.id)).sort()).toEqual(["check", "verify"]);
const { ir: out } = flowToIr("optional", nodes, edges, columns);
if (out.version !== "v2") throw new Error("expected v2");
const opt = out.nodes.find((n) => n.id === "opt");
expect(opt?.kind).toBe("optional-group");
const cfg = opt?.config as Record<string, unknown>;
expect(cfg.defaultOn).toBe(true);
expect(cfg.name).toBe("Browser verification");
const template = cfg.template as { nodes: { id: string }[]; edges: { from: string; to: string }[] };
expect(template.nodes.map((n) => n.id)).toEqual(["verify", "check"]);
expect(template.edges).toEqual([{ from: "verify", to: "check", condition: "success" }]);
// Top-level edges exclude the intra-template ones.
expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual(["start->opt", "opt->end"]);
});
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: Deleting an optional-group must
// cascade its parentId children (no orphans) — same rule foreach/loop follow.
it("cascade-deletes an optional-group's template children", () => {
const optionalIr: WorkflowDefinition["ir"] = {
version: "v2",
name: "optional-del",
columns: ir.columns,
nodes: [
{ id: "start", kind: "start", column: "plan" },
{
id: "opt",
kind: "optional-group",
column: "in-progress",
config: {
defaultOn: false,
template: {
nodes: [{ id: "verify", kind: "prompt", config: { prompt: "verify" } }],
edges: [],
},
},
},
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "opt", condition: "success" },
{ from: "opt", to: "end", condition: "success" },
],
};
const { nodes, edges } = irToFlow(makeDef(optionalIr));
expect(nodes.some((n) => n.parentId === "opt")).toBe(true);
const result = cascadeDelete(nodes, edges, ["opt"]);
expect(result.nodes.some((n) => n.id === "opt")).toBe(false);
expect(result.nodes.some((n) => n.parentId === "opt")).toBe(false);
});
it("inserts loop fragments with their template children intact", () => {
const fragment: WorkflowDefinition["ir"] = {
version: "v2",

View File

@@ -1,5 +1,5 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell } from "lucide-react";
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell, ToggleRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { nodeConfigSummary } from "./node-summary";
import { useWorkflowEditorCatalogs } from "./WorkflowEditorCatalogContext";
@@ -28,6 +28,7 @@ export type WorkflowEditorNodeKind =
| "join"
| "foreach"
| "loop"
| "optional-group"
| WorkflowNodeKindStepReview
| WorkflowNodeKindParseSteps
| "code"
@@ -65,6 +66,7 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
join: Merge,
foreach: Repeat,
loop: Repeat,
"optional-group": ToggleRight,
[WORKFLOW_NODE_KIND_STEP_REVIEW]: ClipboardCheck,
[WORKFLOW_NODE_KIND_PARSE_STEPS]: ListChecks,
code: Code2,
@@ -197,6 +199,42 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
);
}
/*
FNXC:WorkflowOptionalGroup 2026-06-21-11:30:
An `optional-group` renders as a React Flow group container (mirroring `ForeachGroupNode`/`LoopGroupNode`): template nodes are children (parentId = group id). The header shows the group name plus a `defaultOn` badge ("default on" / "default off") so an author can see, at a glance, whether new tasks enable this group. An unregistered kind falls back to `react-flow__node-default` with missing children — registration in `workflowNodeTypes` (below) is what keeps the container rendering with its body.
*/
function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
const { t } = useTranslation("app");
const defaultOn = data.config?.defaultOn === true;
const isEmpty = data.templateEmpty === true;
return (
<div
className={`wf-foreach-group wf-optional-group${data.errorBadge ? " wf-node--error" : ""}`}
data-testid="wf-node-optional-group"
>
<Handle type="target" position={Position.Left} />
<div className="wf-foreach-header">
<span className="wf-node-icon">
<ToggleRight size={14} aria-hidden />
</span>
<span className="wf-node-label">{data.label || "optional-group"}</span>
<span className="wf-node-badge" data-testid="wf-optional-group-default-badge">
{defaultOn
? t("workflowNodes.optionalGroupDefaultOn", "default on")
: t("workflowNodes.optionalGroupDefaultOff", "default off")}
</span>
</div>
{isEmpty && (
<div className="wf-foreach-empty" data-testid="wf-optional-group-empty">
{data.emptyHint || "Drag optional steps here"}
</div>
)}
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
<Handle type="source" position={Position.Right} />
</div>
);
}
export const workflowNodeTypes = {
start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />,
end: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="end" />,
@@ -209,6 +247,7 @@ export const workflowNodeTypes = {
join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />,
foreach: ({ data }: NodeProps) => <ForeachGroupNode data={data as WorkflowFlowNodeData} />,
loop: ({ data }: NodeProps) => <LoopGroupNode data={data as WorkflowFlowNodeData} />,
"optional-group": ({ data }: NodeProps) => <OptionalGroupNode data={data as WorkflowFlowNodeData} />,
"step-review": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="step-review" />,
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />,

View File

@@ -16,6 +16,7 @@ const EDITOR_KINDS = [
"join",
"foreach",
"loop",
"optional-group",
"step-review",
"parse-steps",
"code",

View File

@@ -141,6 +141,18 @@ const NODE_HELP: Record<string, NodeHelp> = {
outputs: "The final iteration's result.",
edges: "One outgoing edge (success) on exit. Exits on condition match, max iterations, or timeout.",
},
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is a container whose body runs once when the task enables it and is skipped otherwise. Enable state is the per-task `enabledWorkflowSteps` facet, seeded from the group's `defaultOn`.
"optional-group": {
title: "Optional group",
summary:
"Holds a group of steps that run only when the task has this group enabled. Enabled tasks run the group's steps once at this position; disabled tasks pass straight through. Renders as a group you drop step nodes into.",
configure:
"Set the group Name and whether it is Enabled by default for new tasks (defaultOn). A task can override the default per-task. Drop the optional steps inside the region.",
inputs: "The task arriving from upstream, plus prior context.",
outputs: "The group's result when enabled; an unchanged pass-through when disabled.",
edges:
"success once the group finishes (or is skipped). A template failure inside an enabled group routes the group's failure edge.",
},
"step-review": {
title: "Step review",
summary:

View File

@@ -38,6 +38,16 @@ interface WorkflowLoopConfig {
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
}
/*
FNXC:WorkflowOptionalGroup 2026-06-21-11:30:
An `optional-group` is a third container kind alongside `foreach`/`loop`. It carries `defaultOn`/`name` plus a `template:{nodes,edges}` subgraph authored inline as React Flow `parentId` children (reusing the `foreachChildFlowId` namespacing). It is special-cased everywhere foreach/loop are: group-template detection, child reassembly in flowToIr, intra-template edge folding, cascade delete, and condition-editability. Single-pass, no rework/iteration — but the editor mapping treats its template identically to foreach/loop.
*/
interface WorkflowOptionalGroupConfig {
defaultOn?: boolean;
name?: string;
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
}
// WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape
// can migrate; callers should prefer WorkflowFieldDefinition directly.
@@ -170,6 +180,7 @@ const SAME_KIND_EDITOR_NODE_KINDS = new Set<WorkflowIrNodeKind>([
"join",
"foreach",
"loop",
"optional-group",
"step-review",
"parse-steps",
"code",
@@ -263,10 +274,17 @@ function loopConfigOf(node: WorkflowIrNode): WorkflowLoopConfig | undefined {
return cfg as WorkflowLoopConfig;
}
function optionalGroupConfigOf(node: WorkflowIrNode): WorkflowOptionalGroupConfig | undefined {
if (node.kind !== "optional-group") return undefined;
const cfg = node.config as Partial<WorkflowOptionalGroupConfig> | undefined;
if (!cfg || !cfg.template) return undefined;
return cfg as WorkflowOptionalGroupConfig;
}
function groupTemplateConfigOf(
node: WorkflowIrNode,
): WorkflowForeachConfig | WorkflowLoopConfig | undefined {
return foreachConfigOf(node) ?? loopConfigOf(node);
): WorkflowForeachConfig | WorkflowLoopConfig | WorkflowOptionalGroupConfig | undefined {
return foreachConfigOf(node) ?? loopConfigOf(node) ?? optionalGroupConfigOf(node);
}
/** CSS class for an edge given its condition + rework kind. Rework takes
@@ -451,7 +469,9 @@ export function flowToIr(
}
}
const groupIds = new Set(
topNodes.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop").map((n) => n.id),
topNodes
.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group")
.map((n) => n.id),
);
const hasFields = Array.isArray(fields) && fields.length > 0;
const hasSettings = Array.isArray(settings) && settings.length > 0;
@@ -477,8 +497,19 @@ export function flowToIr(
}
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
}
if (data.kind === "foreach" || data.kind === "loop" || originalKind === "retry-backoff") {
if (originalKind && originalKind !== "foreach" && originalKind !== "loop" && originalKind !== "retry-backoff") {
if (
data.kind === "foreach" ||
data.kind === "loop" ||
data.kind === "optional-group" ||
originalKind === "retry-backoff"
) {
if (
originalKind &&
originalKind !== "foreach" &&
originalKind !== "loop" &&
originalKind !== "optional-group" &&
originalKind !== "retry-backoff"
) {
return { id: localId, kind: originalKind, config: config && Object.keys(config).length ? config : undefined };
}
// Reassemble the template from this group's children.
@@ -608,9 +639,10 @@ function isProtectedFromDelete(node: FlowNode<WorkflowFlowNodeData>): boolean {
* Delete the requested node and/or edge ids from the flow graph, applying R6's
* cascade rules:
* - Deleting a node removes ALL edges incident to it (no auto-bridging).
* - Deleting a `foreach`/`loop` group node also deletes its template children
* (nodes with `parentId === groupId`) and every edge incident to those
* children (React Flow does not cascade parents — handled explicitly).
* - Deleting a `foreach`/`loop`/`optional-group` group node also deletes its
* template children (nodes with `parentId === groupId`) and every edge
* incident to those children (React Flow does not cascade parents — handled
* explicitly).
* - `start`/`end` nodes and column band nodes are never deleted: they are
* filtered out of the requested ids up front (and their incident edges are
* therefore preserved).
@@ -633,7 +665,7 @@ export function cascadeDelete(
const node = nodeById.get(id);
if (!node || isProtectedFromDelete(node)) continue;
deleteNodeIds.add(id);
if (node.data.kind === "foreach" || node.data.kind === "loop") {
if (node.data.kind === "foreach" || node.data.kind === "loop" || node.data.kind === "optional-group") {
for (const child of nodes) {
if (child.parentId === id) deleteNodeIds.add(child.id);
}
@@ -660,7 +692,7 @@ export function cascadeDelete(
/** Editor node kinds whose edges expose a success/failure condition select
* (KTD-2). step-review uses verdict controls; all other kinds are read-only. */
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop"]);
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop", "optional-group"]);
/** Decide what the edge inspector renders for an edge sourced from `sourceKind`:
* - "verdicts": step-review verdict select + rework checkbox (existing);