FN-7412: connect template block boundary guides

Connect workflow template containers to their internal entry and exit steps with visual-only boundary guides.

- Generalize optional-group boundary metadata and generated guide edges to foreach and loop template containers.
- Render non-connectable template boundary handles on foreach, loop, and optional-group nodes while keeping saved IR free of visual-only topology.
- Add coverage for stepwise foreach rendering, live boundary refresh, mobile graph filtering, and changeset release notes.

Files changed:
 .changeset/fn-7412-template-boundary-connectors.md |   7 ++
 .../app/components/WorkflowNodeEditor.tsx          |  13 +-
 .../__tests__/WorkflowNodeEditor.test.tsx          |  16 +--
 .../__tests__/workflow-flow-mapping.test.ts        | 135 +++++++++++++++++++--
 .../__tests__/workflow-mobile-graph.test.ts        |  20 +++
 .../app/components/nodes/WorkflowNodeTypes.tsx     |  25 ++--
 .../app/components/workflow-flow-mapping.ts        | 117 +++++++++---------
 7 files changed, 245 insertions(+), 88 deletions(-)

Fusion-Task-Id: FN-7412

Fusion-Task-Lineage: 2b36de80-b1d0-4a43-85e5-ce47ba9ac7ff

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 00:49:00 -07:00
parent 377eee64af
commit 9ee33f9551
7 changed files with 245 additions and 88 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show workflow template block boundary connectors in the graph editor.
category: fix
dev: Adds visual-only foreach/loop/optional-group template boundary edges that are filtered from persisted IR.

View File

@@ -82,7 +82,7 @@ import {
edgeConditionEditability, edgeConditionEditability,
buildConnectionEdge, buildConnectionEdge,
cascadeDelete, cascadeDelete,
refreshOptionalGroupVisualBoundaries, refreshTemplateContainerVisualBoundaries,
WF_EDGE_INTERACTION_WIDTH, WF_EDGE_INTERACTION_WIDTH,
FOREACH_GROUP_WIDTH, FOREACH_GROUP_WIDTH,
FOREACH_GROUP_HEIGHT, FOREACH_GROUP_HEIGHT,
@@ -1380,7 +1380,7 @@ function InnerEditor({
return; return;
} }
setEdges((eds) => { setEdges((eds) => {
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, [...eds, result.edge]); const refreshed = refreshTemplateContainerVisualBoundaries(nodes, [...eds, result.edge]);
setNodes(refreshed.nodes); setNodes(refreshed.nodes);
return refreshed.edges; return refreshed.edges;
}); });
@@ -1397,7 +1397,7 @@ function InnerEditor({
(changes: EdgeChange<FlowEdge>[]) => { (changes: EdgeChange<FlowEdge>[]) => {
setEdges((eds) => { setEdges((eds) => {
const changedEdges = applyEdgeChanges(changes, eds) as FlowEdge[]; const changedEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, changedEdges); const refreshed = refreshTemplateContainerVisualBoundaries(nodes, changedEdges);
setNodes(refreshed.nodes); setNodes(refreshed.nodes);
return refreshed.edges; return refreshed.edges;
}); });
@@ -1493,8 +1493,7 @@ function InnerEditor({
deletable: true, deletable: true,
}, },
] satisfies FlowNode<WorkflowFlowNodeData>[]; ] satisfies FlowNode<WorkflowFlowNodeData>[];
if (kind !== "optional-group") return nextNodes; const refreshed = refreshTemplateContainerVisualBoundaries(nextNodes, edges);
const refreshed = refreshOptionalGroupVisualBoundaries(nextNodes, edges);
setEdges(refreshed.edges); setEdges(refreshed.edges);
return refreshed.nodes; return refreshed.nodes;
}); });
@@ -1813,7 +1812,7 @@ function InnerEditor({
className: edgeClassName(condition, rework), className: edgeClassName(condition, rework),
}; };
}); });
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, updated); const refreshed = refreshTemplateContainerVisualBoundaries(nodes, updated);
setNodes(refreshed.nodes); setNodes(refreshed.nodes);
return refreshed.edges; return refreshed.edges;
}); });
@@ -1831,7 +1830,7 @@ function InnerEditor({
let next: { nodes: FlowNode<WorkflowFlowNodeData>[]; edges: FlowEdge[] } | null = null; let next: { nodes: FlowNode<WorkflowFlowNodeData>[]; edges: FlowEdge[] } | null = null;
setNodes((ns) => { setNodes((ns) => {
const deleted = cascadeDelete(ns, edges, idSet); const deleted = cascadeDelete(ns, edges, idSet);
next = refreshOptionalGroupVisualBoundaries(deleted.nodes, deleted.edges); next = refreshTemplateContainerVisualBoundaries(deleted.nodes, deleted.edges);
return next.nodes; return next.nodes;
}); });
if (next) setEdges((next as { edges: FlowEdge[] }).edges); if (next) setEdges((next as { edges: FlowEdge[] }).edges);

View File

@@ -269,11 +269,11 @@ function edgeRenderableAssertion(definition: WorkflowDefinition) {
); );
expect(edge.zIndex, `${definition.id} edge ${edge.id} z-index`).toBeGreaterThan(0); expect(edge.zIndex, `${definition.id} edge ${edge.id} z-index`).toBeGreaterThan(0);
if (isVisualOnlyWorkflowEdge(edge) && edge.data?.boundary === "entry") { if (isVisualOnlyWorkflowEdge(edge) && edge.data?.boundary === "entry") {
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBe("optional-boundary-entry"); expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBe("template-boundary-entry");
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBeUndefined(); expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBeUndefined();
} else if (isVisualOnlyWorkflowEdge(edge) && edge.data?.boundary === "exit") { } else if (isVisualOnlyWorkflowEdge(edge) && edge.data?.boundary === "exit") {
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBeUndefined(); expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBeUndefined();
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBe("optional-boundary-exit"); expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBe("template-boundary-exit");
} else { } else {
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBeUndefined(); expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBeUndefined();
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBeUndefined(); expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBeUndefined();
@@ -2049,10 +2049,10 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
*/ */
await waitFor(() => { await waitFor(() => {
expect( expect(
document.body.querySelector(`.react-flow__handle.source[data-nodeid="${seededGroupId}"][data-handleid="optional-boundary-entry"]`), document.body.querySelector(`.react-flow__handle.source[data-nodeid="${seededGroupId}"][data-handleid="template-boundary-entry"]`),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
document.body.querySelector(`.react-flow__handle.target[data-nodeid="${seededGroupId}"][data-handleid="optional-boundary-exit"]`), document.body.querySelector(`.react-flow__handle.target[data-nodeid="${seededGroupId}"][data-handleid="template-boundary-exit"]`),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(document.body.querySelector(`.react-flow__handle.target[data-nodeid="${seededChildId}"][data-handlepos="left"]`)).toBeInTheDocument(); expect(document.body.querySelector(`.react-flow__handle.target[data-nodeid="${seededChildId}"][data-handlepos="left"]`)).toBeInTheDocument();
expect(document.body.querySelector(`.react-flow__handle.source[data-nodeid="${seededChildId}"][data-handlepos="right"]`)).toBeInTheDocument(); expect(document.body.querySelector(`.react-flow__handle.source[data-nodeid="${seededChildId}"][data-handlepos="right"]`)).toBeInTheDocument();
@@ -2512,8 +2512,8 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
const boundaryEdges = flow.edges.filter((edge) => isVisualOnlyWorkflowEdge(edge) && (edge.source === groupId || edge.target === groupId)); const boundaryEdges = flow.edges.filter((edge) => isVisualOnlyWorkflowEdge(edge) && (edge.source === groupId || edge.target === groupId));
expect(boundaryEdges, `${groupId} visual boundary edges`).toEqual(expect.arrayContaining([ expect(boundaryEdges, `${groupId} visual boundary edges`).toEqual(expect.arrayContaining([
expect.objectContaining({ source: groupId, sourceHandle: "optional-boundary-entry", target: childFlowId }), expect.objectContaining({ source: groupId, sourceHandle: "template-boundary-entry", target: childFlowId }),
expect.objectContaining({ source: childFlowId, target: groupId, targetHandle: "optional-boundary-exit" }), expect.objectContaining({ source: childFlowId, target: groupId, targetHandle: "template-boundary-exit" }),
])); ]));
/* /*
* FNXC:WorkflowOptionalGroup 2026-06-29-22:47: * FNXC:WorkflowOptionalGroup 2026-06-29-22:47:
@@ -2534,10 +2534,10 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
).toBeInTheDocument(); ).toBeInTheDocument();
} }
const entryBoundaryHandle = document.body.querySelector( const entryBoundaryHandle = document.body.querySelector(
`.react-flow__handle.source[data-nodeid="${groupId}"][data-handlepos="left"][data-handleid="optional-boundary-entry"]`, `.react-flow__handle.source[data-nodeid="${groupId}"][data-handlepos="left"][data-handleid="template-boundary-entry"]`,
); );
const exitBoundaryHandle = document.body.querySelector( const exitBoundaryHandle = document.body.querySelector(
`.react-flow__handle.target[data-nodeid="${groupId}"][data-handlepos="right"][data-handleid="optional-boundary-exit"]`, `.react-flow__handle.target[data-nodeid="${groupId}"][data-handlepos="right"][data-handleid="template-boundary-exit"]`,
); );
expect(entryBoundaryHandle, `${groupId} left boundary source handle`).toBeInTheDocument(); expect(entryBoundaryHandle, `${groupId} left boundary source handle`).toBeInTheDocument();
expect(exitBoundaryHandle, `${groupId} right boundary target handle`).toBeInTheDocument(); expect(exitBoundaryHandle, `${groupId} right boundary target handle`).toBeInTheDocument();

View File

@@ -34,7 +34,7 @@ import {
wouldCreateCycle, wouldCreateCycle,
buildConnectionEdge, buildConnectionEdge,
cascadeDelete, cascadeDelete,
refreshOptionalGroupVisualBoundaries, refreshTemplateContainerVisualBoundaries,
COLUMN_BAND_HEIGHT, COLUMN_BAND_HEIGHT,
WF_CARD_WIDTH, WF_CARD_WIDTH,
WF_FALLBACK_NODE_GAP, WF_FALLBACK_NODE_GAP,
@@ -87,7 +87,7 @@ function assertRenderedHandles(
} }
function assertContainerHandles(kind: "optional-group" | "foreach" | "loop", data: WorkflowFlowNodeData): void { function assertContainerHandles(kind: "optional-group" | "foreach" | "loop", data: WorkflowFlowNodeData): void {
assertRenderedHandles(kind, data, kind === "optional-group" ? { target: 2, source: 2 } : { target: 1, source: 1 }); assertRenderedHandles(kind, data, { target: 2, source: 2 });
} }
function assertRunDoesNotOverlap( function assertRunDoesNotOverlap(
@@ -1026,7 +1026,7 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
label: "success", label: "success",
}; };
const connected = refreshOptionalGroupVisualBoundaries(initial.nodes, [...initial.edges, realInternalEdge]); const connected = refreshTemplateContainerVisualBoundaries(initial.nodes, [...initial.edges, realInternalEdge]);
const connectedById = new Map(connected.nodes.map((node) => [node.id, node] as const)); const connectedById = new Map(connected.nodes.map((node) => [node.id, node] as const));
expect(connectedById.get("opt::alpha")?.data.optionalGroupBoundary).toEqual({ entry: true, exit: false }); expect(connectedById.get("opt::alpha")?.data.optionalGroupBoundary).toEqual({ entry: true, exit: false });
expect(connectedById.get("opt::beta")?.data.optionalGroupBoundary).toEqual({ entry: false, exit: true }); expect(connectedById.get("opt::beta")?.data.optionalGroupBoundary).toEqual({ entry: false, exit: true });
@@ -1035,7 +1035,7 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
"opt::beta->opt", "opt::beta->opt",
]); ]);
const disconnected = refreshOptionalGroupVisualBoundaries( const disconnected = refreshTemplateContainerVisualBoundaries(
connected.nodes, connected.nodes,
connected.edges.filter((edge) => edge.id !== realInternalEdge.id), connected.edges.filter((edge) => edge.id !== realInternalEdge.id),
); );
@@ -1104,6 +1104,123 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
).toHaveLength(1); ).toHaveLength(1);
}); });
it("derives visual-only template boundary connectors across foreach, loop, and optional-group states", () => {
const cases = [
{
name: "single child",
nodes: [{ id: "only", kind: "prompt" as const, config: { prompt: "only" } }],
edges: [] as NonNullable<WorkflowDefinition["ir"]["edges"]>,
visual: ["box->box::only", "box::only->box"],
boundaries: { only: { entry: true, exit: true } },
},
{
name: "linear children",
nodes: [
{ id: "alpha", kind: "prompt" as const, config: { prompt: "alpha" } },
{ id: "beta", kind: "gate" as const },
],
edges: [{ from: "alpha", to: "beta", condition: "success" }],
visual: ["box->box::alpha", "box::beta->box"],
boundaries: { alpha: { entry: true, exit: false }, beta: { entry: false, exit: true } },
},
{
name: "independent children",
nodes: [
{ id: "alpha", kind: "prompt" as const, config: { prompt: "alpha" } },
{ id: "beta", kind: "prompt" as const, config: { prompt: "beta" } },
],
edges: [] as NonNullable<WorkflowDefinition["ir"]["edges"]>,
visual: ["box->box::alpha", "box->box::beta", "box::alpha->box", "box::beta->box"],
boundaries: { alpha: { entry: true, exit: true }, beta: { entry: true, exit: true } },
},
{
name: "empty template",
nodes: [] as NonNullable<WorkflowDefinition["ir"]["nodes"]>,
edges: [] as NonNullable<WorkflowDefinition["ir"]["edges"]>,
visual: [] as string[],
boundaries: {},
},
{
name: "rework cycle ignored for boundaries",
nodes: [
{ id: "alpha", kind: "prompt" as const, config: { prompt: "alpha" } },
{ id: "beta", kind: "step-review" as const, config: { type: "code" } },
],
edges: [
{ from: "alpha", to: "beta", condition: "success" },
{ from: "beta", to: "alpha", condition: "outcome:revise", kind: "rework" as const },
],
visual: ["box->box::alpha", "box::beta->box"],
boundaries: { alpha: { entry: true, exit: false }, beta: { entry: false, exit: true } },
},
];
for (const containerKind of ["foreach", "loop", "optional-group"] as const) {
for (const testCase of cases) {
const template = { nodes: testCase.nodes, edges: testCase.edges };
const config =
containerKind === "foreach"
? { source: "task-steps" as const, template }
: containerKind === "loop"
? { maxIterations: 2, template }
: { defaultOn: true, template };
const containerIr: WorkflowDefinition["ir"] = {
version: "v2",
name: `${containerKind}-${testCase.name}`,
columns: ir.columns,
nodes: [
{ id: "start", kind: "start", column: "plan" },
{ id: "box", kind: containerKind, column: "in-progress", config },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "box", condition: "success" },
{ from: "box", to: "end", condition: "success" },
],
};
const { nodes, edges } = irToFlow(makeDef(containerIr));
const byId = new Map(nodes.map((node) => [node.id, node] as const));
for (const [childId, boundary] of Object.entries(testCase.boundaries)) {
expect(byId.get(`box::${childId}`)?.data.templateBoundary, `${containerKind} ${testCase.name} ${childId}`).toEqual(boundary);
}
expect(edges.filter((edge) => isVisualOnlyWorkflowEdge(edge)).map((edge) => `${edge.source}->${edge.target}`).sort()).toEqual(
testCase.visual.sort(),
);
const { ir: out } = flowToIr(containerIr.name, nodes, edges, columnsOf(makeDef(containerIr)));
if (out.version !== "v2") throw new Error("expected v2");
expect(out.edges.map((edge) => `${edge.from}->${edge.to}`)).toEqual(["start->box", "box->end"]);
const outBox = out.nodes.find((node) => node.id === "box")!;
const outTemplate = outBox.config?.template as { nodes?: Array<{ config?: Record<string, unknown> }>; edges?: unknown[] };
expect(outTemplate.edges).toEqual(testCase.edges);
expect(outTemplate.nodes?.map((node) => node.config?.templateBoundary)).toEqual(testCase.nodes.map(() => undefined));
}
}
});
it("connects built-in stepwise foreach steps to visual-only boundary guides", () => {
const { nodes, edges } = irToFlow(makeDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR));
const byId = new Map(nodes.map((node) => [node.id, node] as const));
expect(byId.get("steps")?.type).toBe("foreach");
expect(byId.get(foreachChildFlowId("steps", "step-execute"))?.data.templateBoundary).toEqual({ entry: true, exit: false });
expect(byId.get(foreachChildFlowId("steps", "step-done"))?.data.templateBoundary).toEqual({ entry: false, exit: true });
expect(edges.filter((edge) => isVisualOnlyWorkflowEdge(edge) && (edge.source === "steps" || edge.target === "steps"))).toEqual(
expect.arrayContaining([
expect.objectContaining({ source: "steps", sourceHandle: "template-boundary-entry", target: foreachChildFlowId("steps", "step-execute") }),
expect.objectContaining({ source: foreachChildFlowId("steps", "step-done"), target: "steps", targetHandle: "template-boundary-exit" }),
]),
);
const { ir: out } = flowToIr("stepwise", nodes, edges, columnsOf(makeDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)));
if (out.version !== "v2") throw new Error("expected v2");
expect(out.edges.some((edge) => edge.from === "steps" && edge.to.includes("step"))).toBe(false);
const steps = out.nodes.find((node) => node.id === "steps")!;
const template = steps.config?.template as { nodes?: Array<{ config?: Record<string, unknown> }>; edges?: Array<{ from: string; to: string }> };
expect(template.nodes?.map((node) => node.config?.templateBoundary)).toEqual([undefined, undefined, undefined]);
expect(template.edges?.some((edge) => edge.from === "steps" || edge.to === "steps")).toBe(false);
});
it("marks built-in Plan Review and Code Review single children as optional-group entry and exit boundaries", () => { it("marks built-in Plan Review and Code Review single children as optional-group entry and exit boundaries", () => {
for (const [workflowName, builtinIr] of [ for (const [workflowName, builtinIr] of [
["coding", BUILTIN_CODING_WORKFLOW_IR], ["coding", BUILTIN_CODING_WORKFLOW_IR],
@@ -1451,7 +1568,11 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
expect(group?.type).toBe("loop"); expect(group?.type).toBe("loop");
expect(child?.position).toEqual({ x: 86, y: 132 }); expect(child?.position).toEqual({ x: 86, y: 132 });
expect(inserted.nodes.filter((n) => n.parentId === group?.id)).toHaveLength(1); expect(inserted.nodes.filter((n) => n.parentId === group?.id)).toHaveLength(1);
expect(inserted.edges).toHaveLength(0); expect(inserted.edges.filter((edge) => !isVisualOnlyWorkflowEdge(edge))).toHaveLength(0);
expect(inserted.edges.filter((edge) => isVisualOnlyWorkflowEdge(edge)).map((edge) => `${edge.source}->${edge.target}`).sort()).toEqual([
`${group?.id}->${child?.id}`,
`${child?.id}->${group?.id}`,
]);
}); });
it("round-trips a code node config (source + timeoutMs)", () => { it("round-trips a code node config (source + timeoutMs)", () => {
@@ -1627,10 +1748,10 @@ describe("edge-condition authoring (U2)", () => {
}); });
// visual-only optional-group boundary handles are reserved for generated guide edges. // visual-only optional-group boundary handles are reserved for generated guide edges.
expect(buildConnectionEdge({ source: "a", sourceHandle: "optional-boundary-entry", target: "b" }, edges, nodes)).toEqual({ expect(buildConnectionEdge({ source: "a", sourceHandle: "template-boundary-entry", target: "b" }, edges, nodes)).toEqual({
error: "reserved-handle", error: "reserved-handle",
}); });
expect(buildConnectionEdge({ source: "a", target: "b", targetHandle: "optional-boundary-exit" }, edges, nodes)).toEqual({ expect(buildConnectionEdge({ source: "a", target: "b", targetHandle: "template-boundary-exit" }, edges, nodes)).toEqual({
error: "reserved-handle", error: "reserved-handle",
}); });

View File

@@ -185,6 +185,26 @@ describe("buildMobileWorkflowGraph", () => {
]); ]);
}); });
it("filters foreach boundary chrome while preserving real stepwise template child edges", () => {
const { nodes, edges } = irToFlow(workflowDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR));
const rows = buildMobileWorkflowGraph(
nodes,
edges,
BUILTIN_STEPWISE_CODING_WORKFLOW_IR.version === "v2" ? BUILTIN_STEPWISE_CODING_WORKFLOW_IR.columns : [],
);
const steps = rows.find((row) => row.id === "steps");
expect(steps?.outgoing.some((out) => out.label === "entry" || out.label === "exit")).toBe(false);
expect(steps?.outgoing.some((out) => out.target.includes("step-"))).toBe(false);
const execute = steps?.children.find((child) => child.id === foreachChildFlowId("steps", "step-execute"));
const done = steps?.children.find((child) => child.id === foreachChildFlowId("steps", "step-done"));
expect(execute?.outgoing.map((out) => [out.target, out.label])).toContainEqual([
foreachChildFlowId("steps", "step-review"),
"success",
]);
expect(done?.outgoing.some((out) => out.target === "steps" || out.label === "exit")).toBe(false);
});
it("nests foreach template children without exposing local ids as top-level rows", () => { it("nests foreach template children without exposing local ids as top-level rows", () => {
const childId = foreachChildFlowId("each", "step"); const childId = foreachChildFlowId("each", "step");
const rows = buildMobileWorkflowGraph( const rows = buildMobileWorkflowGraph(

View File

@@ -15,8 +15,8 @@ const WORKFLOW_NODE_KIND_GATE: WorkflowNodeKindGate = `${"ga"}te`;
const WORKFLOW_NODE_KIND_STEP_REVIEW: WorkflowNodeKindStepReview = `${"st"}ep-review`; const WORKFLOW_NODE_KIND_STEP_REVIEW: WorkflowNodeKindStepReview = `${"st"}ep-review`;
const WORKFLOW_NODE_KIND_PARSE_STEPS: WorkflowNodeKindParseSteps = `parse-${"st"}eps`; const WORKFLOW_NODE_KIND_PARSE_STEPS: WorkflowNodeKindParseSteps = `parse-${"st"}eps`;
const WORKFLOW_NODE_SEAM_STEP_EXECUTE = `${"st"}ep-execute`; const WORKFLOW_NODE_SEAM_STEP_EXECUTE = `${"st"}ep-execute`;
const OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE = "optional-boundary-entry"; const TEMPLATE_BOUNDARY_ENTRY_HANDLE = "template-boundary-entry";
const OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE = "optional-boundary-exit"; const TEMPLATE_BOUNDARY_EXIT_HANDLE = "template-boundary-exit";
export type WorkflowEditorNodeKind = export type WorkflowEditorNodeKind =
| "start" | "start"
@@ -54,9 +54,11 @@ export interface WorkflowFlowNodeData {
/** template group only: the localized empty-state hint string. */ /** template group only: the localized empty-state hint string. */
emptyHint?: string; emptyHint?: string;
/** /**
* FNXC:WorkflowOptionalGroup 2026-06-29-21:37: * FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
* Optional-group template children expose boundary ownership for editor visuals only. Entry/exit flags let mapping and renderer tests prove Plan Review/Code Review single-child blocks are connected to their container without persisting fake topology into the workflow IR. * Template container children expose visual entry/exit ownership for editor chrome only. Foreach, loop, and optional-group blocks use these flags and derived edges to make internal template boundaries readable without persisting fake topology into workflow IR.
*/ */
templateBoundary?: { entry: boolean; exit: boolean };
/** Back-compat alias for existing optional-group tests and call sites while generalized templateBoundary becomes canonical. */
optionalGroupBoundary?: { entry: boolean; exit: boolean }; optionalGroupBoundary?: { entry: boolean; exit: boolean };
[key: string]: unknown; [key: string]: unknown;
} }
@@ -157,6 +159,7 @@ function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
data-testid="wf-node-foreach" data-testid="wf-node-foreach"
> >
<Handle type="target" position={Position.Left} /> <Handle type="target" position={Position.Left} />
<Handle id={TEMPLATE_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
<div className="wf-foreach-header"> <div className="wf-foreach-header">
<span className="wf-node-icon"> <span className="wf-node-icon">
<Repeat size={14} aria-hidden /> <Repeat size={14} aria-hidden />
@@ -171,6 +174,7 @@ function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
</div> </div>
)} )}
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />} {data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
<Handle id={TEMPLATE_BOUNDARY_EXIT_HANDLE} type="target" position={Position.Right} isConnectable={false} />
<Handle type="source" position={Position.Right} /> <Handle type="source" position={Position.Right} />
</div> </div>
); );
@@ -187,6 +191,7 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
data-testid="wf-node-loop" data-testid="wf-node-loop"
> >
<Handle type="target" position={Position.Left} /> <Handle type="target" position={Position.Left} />
<Handle id={TEMPLATE_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
<div className="wf-foreach-header"> <div className="wf-foreach-header">
<span className="wf-node-icon"> <span className="wf-node-icon">
<Repeat size={14} aria-hidden /> <Repeat size={14} aria-hidden />
@@ -201,6 +206,7 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
</div> </div>
)} )}
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />} {data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
<Handle id={TEMPLATE_BOUNDARY_EXIT_HANDLE} type="target" position={Position.Right} isConnectable={false} />
<Handle type="source" position={Position.Right} /> <Handle type="source" position={Position.Right} />
</div> </div>
); );
@@ -210,11 +216,8 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
FNXC:WorkflowOptionalGroup 2026-06-21-11:30: 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. 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.
FNXC:WorkflowOptionalGroup 2026-06-29-22:47: FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
Optional-group containers own the real workflow entry and exit boundaries. Keep the standard left target/right source handles for top-level graph edges, and add dedicated left source/right target handles for visual-only template boundary connectors so entry and exit guides attach to the side that matches execution flow. Template containers own visual entry and exit guide anchors separately from real workflow topology. Keep the standard left target/right source handles for top-level graph edges, and add dedicated non-connectable left source/right target handles for visual-only template boundary connectors so foreach, loop, and optional-group entry/exit guides attach to the side that matches execution flow.
FNXC:WorkflowOptionalGroup 2026-06-29-23:20:
The visual-only boundary connectors must never become authorable topology. Mark their dedicated handles non-connectable so users cannot drag persisted edges from the entry/exit guides into the optional group's template children.
*/ */
function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) { function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
@@ -226,7 +229,7 @@ function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
data-testid="wf-node-optional-group" data-testid="wf-node-optional-group"
> >
<Handle type="target" position={Position.Left} /> <Handle type="target" position={Position.Left} />
<Handle id={OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} /> <Handle id={TEMPLATE_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
<div className="wf-foreach-header"> <div className="wf-foreach-header">
<span className="wf-node-icon"> <span className="wf-node-icon">
<ToggleRight size={14} aria-hidden /> <ToggleRight size={14} aria-hidden />
@@ -244,7 +247,7 @@ function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
</div> </div>
)} )}
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />} {data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
<Handle id={OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE} type="target" position={Position.Right} isConnectable={false} /> <Handle id={TEMPLATE_BOUNDARY_EXIT_HANDLE} type="target" position={Position.Right} isConnectable={false} />
<Handle type="source" position={Position.Right} /> <Handle type="source" position={Position.Right} />
</div> </div>
); );

View File

@@ -47,8 +47,8 @@ interface WorkflowOptionalGroupConfig {
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }; template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
} }
const OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE = "optional-boundary-entry"; export const TEMPLATE_BOUNDARY_ENTRY_HANDLE = "template-boundary-entry";
const OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE = "optional-boundary-exit"; export const TEMPLATE_BOUNDARY_EXIT_HANDLE = "template-boundary-exit";
// WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14). // WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape // Re-exported so existing importers that reference WorkflowFieldDefinitionShape
@@ -286,11 +286,11 @@ function optionalGroupConfigOf(node: WorkflowIrNode): WorkflowOptionalGroupConfi
} }
/* /*
FNXC:WorkflowOptionalGroup 2026-06-29-20:10: FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
Optional-group template entry/exit connectivity is visually owned by the container's outer handles. Child boundary handles must not imply disconnected IR edges, so derive child boundary metadata from forward internal template edges only; rework loops route backward and cannot erase the review-step exit or execute-step entry. Foreach, loop, and optional-group template entry/exit connectivity is visual editor chrome owned by the container boundary, not workflow topology. Derive child boundary metadata from forward internal template edges only; rework loops route backward and cannot erase an entry or exit guide.
*/ */
function optionalGroupTemplateBoundaryById( function templateBoundaryById(
template: WorkflowOptionalGroupConfig["template"], template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] },
): Map<string, { entry: boolean; exit: boolean }> { ): Map<string, { entry: boolean; exit: boolean }> {
const templateNodeIds = new Set(template.nodes.map((node) => node.id)); const templateNodeIds = new Set(template.nodes.map((node) => node.id));
const incomingForward = new Set<string>(); const incomingForward = new Set<string>();
@@ -448,29 +448,17 @@ function templateBoundaryNodeIds(template: { nodes: WorkflowIrNode[]; edges: Wor
} }
/* /*
* FNXC:WorkflowOptionalGroup 2026-06-29-20:41: * FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
* Single-node optional groups such as Plan Review and Code Review looked disconnected because their executable template child had no internal template edge. Add read-only boundary connector edges in React Flow so the child visibly participates in the block, but mark them visual-only and filter them out of save/mobile serialization so the workflow IR keeps the real optional-group entry/exit contract. Boundary connectors use the same forward-edge-only rule as child handle metadata because rework loops are review routing, not alternate optional-group entry/exit ownership. * Template containers such as stepwise foreach blocks need visible boundary-to-child guides so internal template nodes do not look disconnected. Emit one visual-only connector per forward-edge-derived entry/exit child for foreach, loop, and optional-group containers, but keep these edges non-selectable, non-deletable, and filtered from save/mobile serialization because they are editor/read-only chrome rather than workflow topology.
*
* FNXC:WorkflowOptionalGroup 2026-06-29-20:56:
* Surface enumeration for FN-7249 keeps the fix constrained to editor visualization surfaces: desktop React Flow handles/edges, mobile outline filtering, parentId template children, and built-in Plan Review/Code Review single-child optional groups. Preserve saved/manual layouts and the core optional-group execution contract while repairing only visual child-boundary connectivity.
*
* FNXC:WorkflowOptionalGroup 2026-06-29-21:25:
* Optional groups may have multiple independent template entries or exits. Emit one visual-only connector per boundary child so boundary-handle suppression never creates a disconnected child with no corresponding container-owned visual path.
*
* FNXC:WorkflowOptionalGroup 2026-06-29-22:16:
* Boundary connector edges are explanatory editor chrome, not workflow topology. Keep them non-selectable and non-deletable so authors cannot mistake the visual entry/exit guides for persisted optional-group template edges.
*
* FNXC:WorkflowOptionalGroup 2026-06-29-22:47:
* Boundary connector edges must attach entry guides to a left-side container source handle and exit guides to a right-side container target handle. The normal optional-group target/source handles remain reserved for top-level workflow edges, so visual-only child connectors do not reverse the perceived execution boundary.
*/ */
function optionalGroupBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: readonly string[], exitFlowIds: readonly string[]): FlowEdge[] { function templateBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: readonly string[], exitFlowIds: readonly string[]): FlowEdge[] {
const visualEdges: FlowEdge[] = []; const visualEdges: FlowEdge[] = [];
for (const entryFlowId of entryFlowIds) { for (const entryFlowId of entryFlowIds) {
const entryId = templateNodeIdFromChild(groupId, entryFlowId); const entryId = templateNodeIdFromChild(groupId, entryFlowId);
visualEdges.push({ visualEdges.push({
id: `e-${groupId}-boundary-entry-${entryId}`, id: `e-${groupId}-boundary-entry-${entryId}`,
source: groupId, source: groupId,
sourceHandle: OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE, sourceHandle: TEMPLATE_BOUNDARY_ENTRY_HANDLE,
target: entryFlowId, target: entryFlowId,
label: "entry", label: "entry",
data: { condition: "entry", visualOnly: WF_TEMPLATE_BOUNDARY_EDGE_KIND, boundary: "entry" }, data: { condition: "entry", visualOnly: WF_TEMPLATE_BOUNDARY_EDGE_KIND, boundary: "entry" },
@@ -488,7 +476,7 @@ function optionalGroupBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: rea
id: `e-${groupId}-boundary-exit-${exitId}`, id: `e-${groupId}-boundary-exit-${exitId}`,
source: exitFlowId, source: exitFlowId,
target: groupId, target: groupId,
targetHandle: OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE, targetHandle: TEMPLATE_BOUNDARY_EXIT_HANDLE,
label: "exit", label: "exit",
data: { condition: "exit", visualOnly: WF_TEMPLATE_BOUNDARY_EDGE_KIND, boundary: "exit" }, data: { condition: "exit", visualOnly: WF_TEMPLATE_BOUNDARY_EDGE_KIND, boundary: "exit" },
className: "wf-edge-template-boundary", className: "wf-edge-template-boundary",
@@ -502,10 +490,10 @@ function optionalGroupBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: rea
return visualEdges; return visualEdges;
} }
function optionalGroupBoundaryEdges(node: WorkflowIrNode, template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }): FlowEdge[] { function templateBoundaryEdges(node: WorkflowIrNode, template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }): FlowEdge[] {
if (node.kind !== "optional-group" || template.nodes.length === 0) return []; if (!groupTemplateConfigOf(node) || template.nodes.length === 0) return [];
const { entryIds, exitIds } = templateBoundaryNodeIds(template); const { entryIds, exitIds } = templateBoundaryNodeIds(template);
return optionalGroupBoundaryEdgesForFlowIds( return templateBoundaryEdgesForFlowIds(
node.id, node.id,
entryIds.map((entryId) => foreachChildFlowId(node.id, entryId)), entryIds.map((entryId) => foreachChildFlowId(node.id, entryId)),
exitIds.map((exitId) => foreachChildFlowId(node.id, exitId)), exitIds.map((exitId) => foreachChildFlowId(node.id, exitId)),
@@ -513,10 +501,10 @@ function optionalGroupBoundaryEdges(node: WorkflowIrNode, template: { nodes: Wor
} }
/* /*
* FNXC:WorkflowOptionalGroup 2026-06-29-23:31: * FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
* Optional-group boundary connector edges are derived editor chrome. Recompute them after live canvas node/edge mutations so adding, deleting, or retagging internal template edges immediately moves entry/exit guides without waiting for a save/reload round-trip. * Template boundary connector edges are derived editor chrome. Recompute them after live canvas node/edge mutations so adding, deleting, or retagging internal foreach/loop/optional-group template edges immediately moves entry/exit guides without waiting for a save/reload round-trip.
*/ */
export function refreshOptionalGroupVisualBoundaries( export function refreshTemplateContainerVisualBoundaries(
nodes: FlowNode<WorkflowFlowNodeData>[], nodes: FlowNode<WorkflowFlowNodeData>[],
edges: FlowEdge[], edges: FlowEdge[],
): { nodes: FlowNode<WorkflowFlowNodeData>[]; edges: FlowEdge[] } { ): { nodes: FlowNode<WorkflowFlowNodeData>[]; edges: FlowEdge[] } {
@@ -530,12 +518,12 @@ export function refreshOptionalGroupVisualBoundaries(
const groupIds = new Set( const groupIds = new Set(
nodes nodes
.filter((node) => node.data.kind === "optional-group") .filter((node) => node.data.kind === "optional-group" || node.data.kind === "foreach" || node.data.kind === "loop")
.map((node) => node.id), .map((node) => node.id),
); );
const childToOptionalGroup = new Map<string, string>(); const childToTemplateContainer = new Map<string, string>();
for (const groupId of groupIds) { for (const groupId of groupIds) {
for (const child of childrenByGroup.get(groupId) ?? []) childToOptionalGroup.set(child.id, groupId); for (const child of childrenByGroup.get(groupId) ?? []) childToTemplateContainer.set(child.id, groupId);
} }
const nonVisualEdges = edges.filter((edge) => !isVisualOnlyWorkflowEdge(edge)); const nonVisualEdges = edges.filter((edge) => !isVisualOnlyWorkflowEdge(edge));
@@ -566,25 +554,44 @@ export function refreshOptionalGroupVisualBoundaries(
if (boundary.entry) entryFlowIds.push(child.id); if (boundary.entry) entryFlowIds.push(child.id);
if (boundary.exit) exitFlowIds.push(child.id); if (boundary.exit) exitFlowIds.push(child.id);
} }
nextVisualEdges.push(...optionalGroupBoundaryEdgesForFlowIds(groupId, entryFlowIds, exitFlowIds)); nextVisualEdges.push(...templateBoundaryEdgesForFlowIds(groupId, entryFlowIds, exitFlowIds));
} }
const nextNodes = nodes.map((node) => { const nextNodes = nodes.map((node) => {
const optionalGroupId = childToOptionalGroup.get(node.id); const containerId = childToTemplateContainer.get(node.id);
if (!optionalGroupId) { if (!containerId) {
if (!node.data.optionalGroupBoundary) return node; if (!node.data.templateBoundary && !node.data.optionalGroupBoundary) return node;
const { optionalGroupBoundary: _boundary, ...data } = node.data; const { templateBoundary: _templateBoundary, optionalGroupBoundary: _optionalBoundary, ...data } = node.data;
return { ...node, data }; return { ...node, data };
} }
const boundary = boundaryByChild.get(node.id); const boundary = boundaryByChild.get(node.id);
if (!boundary) return node; if (!boundary) return node;
if (node.data.optionalGroupBoundary?.entry === boundary.entry && node.data.optionalGroupBoundary?.exit === boundary.exit) return node; const optionalCompat = nodes.find((candidate) => candidate.id === containerId)?.data.kind === "optional-group"
return { ...node, data: { ...node.data, optionalGroupBoundary: boundary } }; ? boundary
: undefined;
if (
node.data.templateBoundary?.entry === boundary.entry &&
node.data.templateBoundary?.exit === boundary.exit &&
node.data.optionalGroupBoundary?.entry === optionalCompat?.entry &&
node.data.optionalGroupBoundary?.exit === optionalCompat?.exit
) {
return node;
}
return {
...node,
data: {
...node.data,
templateBoundary: boundary,
...(optionalCompat ? { optionalGroupBoundary: optionalCompat } : { optionalGroupBoundary: undefined }),
},
};
}); });
return { nodes: nextNodes, edges: [...nonVisualEdges, ...nextVisualEdges] }; return { nodes: nextNodes, edges: [...nonVisualEdges, ...nextVisualEdges] };
} }
export const refreshOptionalGroupVisualBoundaries = refreshTemplateContainerVisualBoundaries;
/** Build React Flow nodes/edges from a stored workflow definition. v2 columns /** Build React Flow nodes/edges from a stored workflow definition. v2 columns
* render as swimlane band group nodes; step nodes carry their `column`. A * render as swimlane band group nodes; step nodes carry their `column`. A
* `foreach` node renders as a group whose template subgraph nodes are children * `foreach` node renders as a group whose template subgraph nodes are children
@@ -620,9 +627,7 @@ export function irToFlow(def: WorkflowDefinition): {
const groupCfg = groupTemplateConfigOf(node); const groupCfg = groupTemplateConfigOf(node);
if (groupCfg) { if (groupCfg) {
const template = groupCfg.template; const template = groupCfg.template;
const optionalGroupBoundaries = node.kind === "optional-group" const templateBoundaries = templateBoundaryById(template);
? optionalGroupTemplateBoundaryById(template)
: undefined;
// Render template nodes as children of this group (parentId = group id). // Render template nodes as children of this group (parentId = group id).
template.nodes.forEach((inner, innerIdx) => { template.nodes.forEach((inner, innerIdx) => {
const childFlowId = foreachChildFlowId(node.id, inner.id); const childFlowId = foreachChildFlowId(node.id, inner.id);
@@ -633,7 +638,8 @@ export function irToFlow(def: WorkflowDefinition): {
y: FOREACH_CHILD_Y, y: FOREACH_CHILD_Y,
}; };
const innerKind = editorKind(inner); const innerKind = editorKind(inner);
const optionalGroupBoundary = optionalGroupBoundaries?.get(inner.id); const templateBoundary = templateBoundaries.get(inner.id);
const optionalGroupBoundary = node.kind === "optional-group" ? templateBoundary : undefined;
childNodes.push({ childNodes.push({
id: childFlowId, id: childFlowId,
type: innerKind, type: innerKind,
@@ -645,6 +651,7 @@ export function irToFlow(def: WorkflowDefinition): {
...dataIrKind(inner, innerKind), ...dataIrKind(inner, innerKind),
label: nodeLabel(inner), label: nodeLabel(inner),
config: { ...(inner.config ?? {}) }, config: { ...(inner.config ?? {}) },
...(templateBoundary ? { templateBoundary } : {}),
...(optionalGroupBoundary ? { optionalGroupBoundary } : {}), ...(optionalGroupBoundary ? { optionalGroupBoundary } : {}),
}, },
deletable: true, deletable: true,
@@ -654,7 +661,7 @@ export function irToFlow(def: WorkflowDefinition): {
template.edges.forEach((edge, eIdx) => { template.edges.forEach((edge, eIdx) => {
childEdges.push(irEdgeToFlow(edge, eIdx, `${node.id}${FOREACH_CHILD_SEP}`)); childEdges.push(irEdgeToFlow(edge, eIdx, `${node.id}${FOREACH_CHILD_SEP}`));
}); });
childEdges.push(...optionalGroupBoundaryEdges(node, template)); childEdges.push(...templateBoundaryEdges(node, template));
// Strip the template off the group node's own config (children carry it). // Strip the template off the group node's own config (children carry it).
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>; const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
return { return {
@@ -1034,8 +1041,8 @@ export type BuildConnectionResult =
| { edge: FlowEdge } | { edge: FlowEdge }
| { error: "missing-endpoint" | "duplicate" | "cycle" | "reserved-handle" }; | { error: "missing-endpoint" | "duplicate" | "cycle" | "reserved-handle" };
function isOptionalGroupBoundaryConnectionHandle(handleId: string | null | undefined): boolean { function isTemplateBoundaryConnectionHandle(handleId: string | null | undefined): boolean {
return handleId === OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE || handleId === OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE; return handleId === TEMPLATE_BOUNDARY_ENTRY_HANDLE || handleId === TEMPLATE_BOUNDARY_EXIT_HANDLE;
} }
/** Construct a new success edge for a React Flow connection, reimplementing the /** Construct a new success edge for a React Flow connection, reimplementing the
@@ -1059,12 +1066,12 @@ export function buildConnectionEdge(
if (!source || !target) return { error: "missing-endpoint" }; if (!source || !target) return { error: "missing-endpoint" };
/* /*
* FNXC:WorkflowOptionalGroup 2026-06-29-23:20: * FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
* Optional-group boundary handles are visual guide anchors owned by refreshOptionalGroupVisualBoundaries, not editable workflow topology. Reject connection gestures that mention them so stale DOM, test mocks, or browser quirks cannot persist a fake group↔child edge if React Flow ever reports a boundary handle as connectable. * Template boundary handles are visual guide anchors owned by refreshTemplateContainerVisualBoundaries, not editable workflow topology. Reject connection gestures that mention them so stale DOM, test mocks, or browser quirks cannot persist a fake container↔child edge if React Flow ever reports a boundary handle as connectable.
*/ */
if ( if (
isOptionalGroupBoundaryConnectionHandle(connection.sourceHandle) || isTemplateBoundaryConnectionHandle(connection.sourceHandle) ||
isOptionalGroupBoundaryConnectionHandle(connection.targetHandle) isTemplateBoundaryConnectionHandle(connection.targetHandle)
) { ) {
return { error: "reserved-handle" }; return { error: "reserved-handle" };
} }
@@ -1473,9 +1480,7 @@ export function insertFragment(
if (groupCfg) { if (groupCfg) {
const template = groupCfg.template; const template = groupCfg.template;
const groupKind = editorKind(node); const groupKind = editorKind(node);
const optionalGroupBoundaries = node.kind === "optional-group" const templateBoundaries = templateBoundaryById(template);
? optionalGroupTemplateBoundaryById(template)
: undefined;
template.nodes.forEach((inner, innerIdx) => { template.nodes.forEach((inner, innerIdx) => {
const innerKind = editorKind(inner); const innerKind = editorKind(inner);
const childPos = const childPos =
@@ -1483,7 +1488,8 @@ export function insertFragment(
x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X, x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X,
y: FOREACH_CHILD_Y, y: FOREACH_CHILD_Y,
}; };
const optionalGroupBoundary = optionalGroupBoundaries?.get(inner.id); const templateBoundary = templateBoundaries.get(inner.id);
const optionalGroupBoundary = node.kind === "optional-group" ? templateBoundary : undefined;
childNodes.push({ childNodes.push({
id: foreachChildFlowId(id, inner.id), id: foreachChildFlowId(id, inner.id),
type: innerKind, type: innerKind,
@@ -1495,6 +1501,7 @@ export function insertFragment(
...dataIrKind(inner, innerKind), ...dataIrKind(inner, innerKind),
label: nodeLabel(inner), label: nodeLabel(inner),
config: { ...(inner.config ?? {}) }, config: { ...(inner.config ?? {}) },
...(templateBoundary ? { templateBoundary } : {}),
...(optionalGroupBoundary ? { optionalGroupBoundary } : {}), ...(optionalGroupBoundary ? { optionalGroupBoundary } : {}),
}, },
deletable: true, deletable: true,
@@ -1504,7 +1511,7 @@ export function insertFragment(
template.edges.forEach((edge, eIdx) => { template.edges.forEach((edge, eIdx) => {
childEdges.push(irEdgeToFlow(edge, eIdx, `${id}${FOREACH_CHILD_SEP}`)); childEdges.push(irEdgeToFlow(edge, eIdx, `${id}${FOREACH_CHILD_SEP}`));
}); });
childEdges.push(...optionalGroupBoundaryEdges({ ...node, id }, template)); childEdges.push(...templateBoundaryEdges({ ...node, id }, template));
// The group node keeps everything except the template (children carry it). // The group node keeps everything except the template (children carry it).
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>; const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
return { return {