diff --git a/.changeset/fn-7412-template-boundary-connectors.md b/.changeset/fn-7412-template-boundary-connectors.md new file mode 100644 index 0000000000..691d90f33b --- /dev/null +++ b/.changeset/fn-7412-template-boundary-connectors.md @@ -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. diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 2e53a95ce2..c540d10fbe 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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[]) => { 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[]; - 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[]; 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); diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 710d23e025..f1d49cd5ac 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index 9711869c23..70a202ecca 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -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, + 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, + 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, + edges: [] as NonNullable, + 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 }>; 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 }>; 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", }); diff --git a/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts b/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts index 39b8dd6332..186b03b46f 100644 --- a/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts @@ -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( diff --git a/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx b/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx index 8020669f74..9a58883b16 100644 --- a/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx +++ b/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx @@ -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" > +
@@ -171,6 +174,7 @@ function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
)} {data.errorBadge && } + ); @@ -187,6 +191,7 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) { data-testid="wf-node-loop" > +
@@ -201,6 +206,7 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
)} {data.errorBadge && } + ); @@ -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" > - +
@@ -244,7 +247,7 @@ function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
)} {data.errorBadge && } - + ); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index b526dd08c3..85572793f7 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -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 { const templateNodeIds = new Set(template.nodes.map((node) => node.id)); const incomingForward = new Set(); @@ -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[], edges: FlowEdge[], ): { nodes: FlowNode[]; 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(); + const childToTemplateContainer = new Map(); 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; 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; return {