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,
buildConnectionEdge,
cascadeDelete,
refreshOptionalGroupVisualBoundaries,
refreshTemplateContainerVisualBoundaries,
WF_EDGE_INTERACTION_WIDTH,
FOREACH_GROUP_WIDTH,
FOREACH_GROUP_HEIGHT,
@@ -1380,7 +1380,7 @@ function InnerEditor({
return;
}
setEdges((eds) => {
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, [...eds, result.edge]);
const refreshed = refreshTemplateContainerVisualBoundaries(nodes, [...eds, result.edge]);
setNodes(refreshed.nodes);
return refreshed.edges;
});
@@ -1397,7 +1397,7 @@ function InnerEditor({
(changes: EdgeChange<FlowEdge>[]) => {
setEdges((eds) => {
const changedEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, changedEdges);
const refreshed = refreshTemplateContainerVisualBoundaries(nodes, changedEdges);
setNodes(refreshed.nodes);
return refreshed.edges;
});
@@ -1493,8 +1493,7 @@ function InnerEditor({
deletable: true,
},
] satisfies FlowNode<WorkflowFlowNodeData>[];
if (kind !== "optional-group") return nextNodes;
const refreshed = refreshOptionalGroupVisualBoundaries(nextNodes, edges);
const refreshed = refreshTemplateContainerVisualBoundaries(nextNodes, edges);
setEdges(refreshed.edges);
return refreshed.nodes;
});
@@ -1813,7 +1812,7 @@ function InnerEditor({
className: edgeClassName(condition, rework),
};
});
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, updated);
const refreshed = refreshTemplateContainerVisualBoundaries(nodes, updated);
setNodes(refreshed.nodes);
return refreshed.edges;
});
@@ -1831,7 +1830,7 @@ function InnerEditor({
let next: { nodes: FlowNode<WorkflowFlowNodeData>[]; edges: FlowEdge[] } | null = null;
setNodes((ns) => {
const deleted = cascadeDelete(ns, edges, idSet);
next = refreshOptionalGroupVisualBoundaries(deleted.nodes, deleted.edges);
next = refreshTemplateContainerVisualBoundaries(deleted.nodes, deleted.edges);
return next.nodes;
});
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);
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();
} else if (isVisualOnlyWorkflowEdge(edge) && edge.data?.boundary === "exit") {
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 {
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source 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(() => {
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();
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();
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();
@@ -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));
expect(boundaryEdges, `${groupId} visual boundary edges`).toEqual(expect.arrayContaining([
expect.objectContaining({ source: groupId, sourceHandle: "optional-boundary-entry", target: childFlowId }),
expect.objectContaining({ source: childFlowId, target: groupId, targetHandle: "optional-boundary-exit" }),
expect.objectContaining({ source: groupId, sourceHandle: "template-boundary-entry", target: childFlowId }),
expect.objectContaining({ source: childFlowId, target: groupId, targetHandle: "template-boundary-exit" }),
]));
/*
* FNXC:WorkflowOptionalGroup 2026-06-29-22:47:
@@ -2534,10 +2534,10 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
).toBeInTheDocument();
}
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(
`.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(exitBoundaryHandle, `${groupId} right boundary target handle`).toBeInTheDocument();

View File

@@ -34,7 +34,7 @@ import {
wouldCreateCycle,
buildConnectionEdge,
cascadeDelete,
refreshOptionalGroupVisualBoundaries,
refreshTemplateContainerVisualBoundaries,
COLUMN_BAND_HEIGHT,
WF_CARD_WIDTH,
WF_FALLBACK_NODE_GAP,
@@ -87,7 +87,7 @@ function assertRenderedHandles(
}
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(
@@ -1026,7 +1026,7 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
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));
expect(connectedById.get("opt::alpha")?.data.optionalGroupBoundary).toEqual({ entry: true, exit: false });
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",
]);
const disconnected = refreshOptionalGroupVisualBoundaries(
const disconnected = refreshTemplateContainerVisualBoundaries(
connected.nodes,
connected.edges.filter((edge) => edge.id !== realInternalEdge.id),
);
@@ -1104,6 +1104,123 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
).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", () => {
for (const [workflowName, builtinIr] of [
["coding", BUILTIN_CODING_WORKFLOW_IR],
@@ -1451,7 +1568,11 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
expect(group?.type).toBe("loop");
expect(child?.position).toEqual({ x: 86, y: 132 });
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)", () => {
@@ -1627,10 +1748,10 @@ describe("edge-condition authoring (U2)", () => {
});
// 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",
});
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",
});

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", () => {
const childId = foreachChildFlowId("each", "step");
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_PARSE_STEPS: WorkflowNodeKindParseSteps = `parse-${"st"}eps`;
const WORKFLOW_NODE_SEAM_STEP_EXECUTE = `${"st"}ep-execute`;
const OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE = "optional-boundary-entry";
const OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE = "optional-boundary-exit";
const TEMPLATE_BOUNDARY_ENTRY_HANDLE = "template-boundary-entry";
const TEMPLATE_BOUNDARY_EXIT_HANDLE = "template-boundary-exit";
export type WorkflowEditorNodeKind =
| "start"
@@ -54,9 +54,11 @@ export interface WorkflowFlowNodeData {
/** template group only: the localized empty-state hint string. */
emptyHint?: string;
/**
* FNXC:WorkflowOptionalGroup 2026-06-29-21:37:
* 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.
* FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
* 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 };
[key: string]: unknown;
}
@@ -157,6 +159,7 @@ function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
data-testid="wf-node-foreach"
>
<Handle type="target" position={Position.Left} />
<Handle id={TEMPLATE_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
<div className="wf-foreach-header">
<span className="wf-node-icon">
<Repeat size={14} aria-hidden />
@@ -171,6 +174,7 @@ function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
</div>
)}
{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} />
</div>
);
@@ -187,6 +191,7 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
data-testid="wf-node-loop"
>
<Handle type="target" position={Position.Left} />
<Handle id={TEMPLATE_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
<div className="wf-foreach-header">
<span className="wf-node-icon">
<Repeat size={14} aria-hidden />
@@ -201,6 +206,7 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
</div>
)}
{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} />
</div>
);
@@ -210,11 +216,8 @@ 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.
FNXC:WorkflowOptionalGroup 2026-06-29-22:47:
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.
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.
FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
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.
*/
function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
const { t } = useTranslation("app");
@@ -226,7 +229,7 @@ function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
data-testid="wf-node-optional-group"
>
<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">
<span className="wf-node-icon">
<ToggleRight size={14} aria-hidden />
@@ -244,7 +247,7 @@ function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
</div>
)}
{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} />
</div>
);

View File

@@ -47,8 +47,8 @@ interface WorkflowOptionalGroupConfig {
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
}
const OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE = "optional-boundary-entry";
const OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE = "optional-boundary-exit";
export const TEMPLATE_BOUNDARY_ENTRY_HANDLE = "template-boundary-entry";
export const TEMPLATE_BOUNDARY_EXIT_HANDLE = "template-boundary-exit";
// WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape
@@ -286,11 +286,11 @@ function optionalGroupConfigOf(node: WorkflowIrNode): WorkflowOptionalGroupConfi
}
/*
FNXC:WorkflowOptionalGroup 2026-06-29-20:10:
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.
FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
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(
template: WorkflowOptionalGroupConfig["template"],
function templateBoundaryById(
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] },
): Map<string, { entry: boolean; exit: boolean }> {
const templateNodeIds = new Set(template.nodes.map((node) => node.id));
const incomingForward = new Set<string>();
@@ -448,29 +448,17 @@ function templateBoundaryNodeIds(template: { nodes: WorkflowIrNode[]; edges: Wor
}
/*
* FNXC:WorkflowOptionalGroup 2026-06-29-20:41:
* 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.
*
* 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.
* FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
* 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.
*/
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[] = [];
for (const entryFlowId of entryFlowIds) {
const entryId = templateNodeIdFromChild(groupId, entryFlowId);
visualEdges.push({
id: `e-${groupId}-boundary-entry-${entryId}`,
source: groupId,
sourceHandle: OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE,
sourceHandle: TEMPLATE_BOUNDARY_ENTRY_HANDLE,
target: entryFlowId,
label: "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}`,
source: exitFlowId,
target: groupId,
targetHandle: OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE,
targetHandle: TEMPLATE_BOUNDARY_EXIT_HANDLE,
label: "exit",
data: { condition: "exit", visualOnly: WF_TEMPLATE_BOUNDARY_EDGE_KIND, boundary: "exit" },
className: "wf-edge-template-boundary",
@@ -502,10 +490,10 @@ function optionalGroupBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: rea
return visualEdges;
}
function optionalGroupBoundaryEdges(node: WorkflowIrNode, template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }): FlowEdge[] {
if (node.kind !== "optional-group" || template.nodes.length === 0) return [];
function templateBoundaryEdges(node: WorkflowIrNode, template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }): FlowEdge[] {
if (!groupTemplateConfigOf(node) || template.nodes.length === 0) return [];
const { entryIds, exitIds } = templateBoundaryNodeIds(template);
return optionalGroupBoundaryEdgesForFlowIds(
return templateBoundaryEdgesForFlowIds(
node.id,
entryIds.map((entryId) => foreachChildFlowId(node.id, entryId)),
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:
* 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.
* FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
* 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>[],
edges: FlowEdge[],
): { nodes: FlowNode<WorkflowFlowNodeData>[]; edges: FlowEdge[] } {
@@ -530,12 +518,12 @@ export function refreshOptionalGroupVisualBoundaries(
const groupIds = new Set(
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),
);
const childToOptionalGroup = new Map<string, string>();
const childToTemplateContainer = new Map<string, string>();
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));
@@ -566,25 +554,44 @@ export function refreshOptionalGroupVisualBoundaries(
if (boundary.entry) entryFlowIds.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 optionalGroupId = childToOptionalGroup.get(node.id);
if (!optionalGroupId) {
if (!node.data.optionalGroupBoundary) return node;
const { optionalGroupBoundary: _boundary, ...data } = node.data;
const containerId = childToTemplateContainer.get(node.id);
if (!containerId) {
if (!node.data.templateBoundary && !node.data.optionalGroupBoundary) return node;
const { templateBoundary: _templateBoundary, optionalGroupBoundary: _optionalBoundary, ...data } = node.data;
return { ...node, data };
}
const boundary = boundaryByChild.get(node.id);
if (!boundary) return node;
if (node.data.optionalGroupBoundary?.entry === boundary.entry && node.data.optionalGroupBoundary?.exit === boundary.exit) return node;
return { ...node, data: { ...node.data, optionalGroupBoundary: boundary } };
const optionalCompat = nodes.find((candidate) => candidate.id === containerId)?.data.kind === "optional-group"
? 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] };
}
export const refreshOptionalGroupVisualBoundaries = refreshTemplateContainerVisualBoundaries;
/** Build React Flow nodes/edges from a stored workflow definition. v2 columns
* render as swimlane band group nodes; step nodes carry their `column`. A
* `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);
if (groupCfg) {
const template = groupCfg.template;
const optionalGroupBoundaries = node.kind === "optional-group"
? optionalGroupTemplateBoundaryById(template)
: undefined;
const templateBoundaries = templateBoundaryById(template);
// Render template nodes as children of this group (parentId = group id).
template.nodes.forEach((inner, innerIdx) => {
const childFlowId = foreachChildFlowId(node.id, inner.id);
@@ -633,7 +638,8 @@ export function irToFlow(def: WorkflowDefinition): {
y: FOREACH_CHILD_Y,
};
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({
id: childFlowId,
type: innerKind,
@@ -645,6 +651,7 @@ export function irToFlow(def: WorkflowDefinition): {
...dataIrKind(inner, innerKind),
label: nodeLabel(inner),
config: { ...(inner.config ?? {}) },
...(templateBoundary ? { templateBoundary } : {}),
...(optionalGroupBoundary ? { optionalGroupBoundary } : {}),
},
deletable: true,
@@ -654,7 +661,7 @@ export function irToFlow(def: WorkflowDefinition): {
template.edges.forEach((edge, eIdx) => {
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).
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
return {
@@ -1034,8 +1041,8 @@ export type BuildConnectionResult =
| { edge: FlowEdge }
| { error: "missing-endpoint" | "duplicate" | "cycle" | "reserved-handle" };
function isOptionalGroupBoundaryConnectionHandle(handleId: string | null | undefined): boolean {
return handleId === OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE || handleId === OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE;
function isTemplateBoundaryConnectionHandle(handleId: string | null | undefined): boolean {
return handleId === TEMPLATE_BOUNDARY_ENTRY_HANDLE || handleId === TEMPLATE_BOUNDARY_EXIT_HANDLE;
}
/** 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" };
/*
* FNXC:WorkflowOptionalGroup 2026-06-29-23:20:
* 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.
* FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
* 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 (
isOptionalGroupBoundaryConnectionHandle(connection.sourceHandle) ||
isOptionalGroupBoundaryConnectionHandle(connection.targetHandle)
isTemplateBoundaryConnectionHandle(connection.sourceHandle) ||
isTemplateBoundaryConnectionHandle(connection.targetHandle)
) {
return { error: "reserved-handle" };
}
@@ -1473,9 +1480,7 @@ export function insertFragment(
if (groupCfg) {
const template = groupCfg.template;
const groupKind = editorKind(node);
const optionalGroupBoundaries = node.kind === "optional-group"
? optionalGroupTemplateBoundaryById(template)
: undefined;
const templateBoundaries = templateBoundaryById(template);
template.nodes.forEach((inner, innerIdx) => {
const innerKind = editorKind(inner);
const childPos =
@@ -1483,7 +1488,8 @@ export function insertFragment(
x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X,
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({
id: foreachChildFlowId(id, inner.id),
type: innerKind,
@@ -1495,6 +1501,7 @@ export function insertFragment(
...dataIrKind(inner, innerKind),
label: nodeLabel(inner),
config: { ...(inner.config ?? {}) },
...(templateBoundary ? { templateBoundary } : {}),
...(optionalGroupBoundary ? { optionalGroupBoundary } : {}),
},
deletable: true,
@@ -1504,7 +1511,7 @@ export function insertFragment(
template.edges.forEach((edge, eIdx) => {
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).
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
return {