diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index f9475124b8..a1dc9eb45f 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -639,6 +639,7 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => { function builtinStepwiseDef(): WorkflowDefinition { return { id: "builtin:stepwise-coding", + kind: "workflow", name: "Stepwise coding (built-in)", description: "", ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR, 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 94e72402e6..d6858ad1df 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import type { WorkflowDefinition } from "@fusion/core"; +import { parseWorkflowIr } from "@fusion/core"; import type { Node as FlowNode } from "@xyflow/react"; import { irToFlow, flowToIr, + insertFragment, + fragmentSeamConflicts, + copyIrWithFreshIds, columnsOf, columnForY, bandTop, @@ -752,3 +756,247 @@ describe("cascadeDelete (U3, R6)", () => { expect(result.edges).toHaveLength(edges.length - 1); }); }); + +// ── Fragment insertion + graph copy (U8, R7/R8) ────────────────────────────── + +/** start → a → b → c → end (top-level scope, reused by the U8 suites). */ +const u8ChainDef = (): WorkflowDefinition => + makeDef({ + version: "v1", + name: "chain", + nodes: [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt", config: { prompt: "a" } }, + { id: "b", kind: "prompt", config: { prompt: "b" } }, + { id: "c", kind: "prompt", config: { prompt: "c" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "a", condition: "success" }, + { from: "a", to: "b", condition: "success" }, + { from: "b", to: "c", condition: "success" }, + { from: "c", to: "end", condition: "success" }, + ], + }); + +/** A small fragment IR: start → a → b → end, b carries a merge seam + name. */ +function fragmentIr(): WorkflowDefinition["ir"] { + return { + version: "v1", + name: "frag", + nodes: [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt", config: { prompt: "do a", name: "Step A" } }, + { id: "b", kind: "prompt", config: { seam: "merge", name: "Merge" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "a", condition: "success" }, + { from: "a", to: "b", condition: "failure" }, + { from: "b", to: "end", condition: "success" }, + ], + }; +} + +describe("insertFragment", () => { + it("strips start/end, remaps every node id, preserves internal edges/config/conditions", () => { + const existing = irToFlow(u8ChainDef()); + const before = new Set(existing.nodes.map((n) => n.id)); + const { nodes, edges, insertedNodeIds } = insertFragment( + existing.nodes, + existing.edges, + fragmentIr(), + { x: 500, y: 200 }, + ); + + // Two body nodes inserted (start/end stripped). + expect(insertedNodeIds).toHaveLength(2); + expect(nodes).toHaveLength(existing.nodes.length + 2); + + // Inserted ids are fresh (disjoint from existing) and not the fragment's. + for (const id of insertedNodeIds) { + expect(before.has(id)).toBe(false); + expect(["start", "a", "b", "end"]).not.toContain(id); + } + + const inserted = nodes.filter((n) => insertedNodeIds.includes(n.id)); + // No start/end among inserted nodes. + expect(inserted.some((n) => n.data.kind === "start" || n.data.kind === "end")).toBe(false); + // Config preserved: the merge node keeps its seam; the prompt keeps its prompt. + const merge = inserted.find((n) => n.data.kind === "merge")!; + expect(merge.data.config?.seam).toBe("merge"); + const promptNode = inserted.find((n) => n.data.config?.prompt === "do a")!; + expect(promptNode).toBeTruthy(); + + // Only the single internal a→b edge survives (start→a, b→end stripped). + const newEdges = edges.slice(existing.edges.length); + expect(newEdges).toHaveLength(1); + const e = newEdges[0]; + expect(insertedNodeIds).toContain(e.source); + expect(insertedNodeIds).toContain(e.target); + // Edge condition preserved. + expect(e.data?.condition).toBe("failure"); + + // Inputs not mutated. + expect(existing.nodes).toHaveLength(before.size); + }); + + it("positions inserted nodes near the requested position", () => { + const existing = irToFlow(u8ChainDef()); + const { nodes, insertedNodeIds } = insertFragment( + existing.nodes, + existing.edges, + fragmentIr(), + { x: 500, y: 200 }, + ); + const inserted = nodes.filter((n) => insertedNodeIds.includes(n.id)); + for (const n of inserted) { + expect(n.position.x).toBeGreaterThanOrEqual(500); + expect(n.position.y).toBeGreaterThanOrEqual(200); + expect(n.position.y).toBeLessThan(500); + } + }); + + it("double-insert of the same fragment yields two disjoint id sets", () => { + const existing = irToFlow(u8ChainDef()); + const first = insertFragment(existing.nodes, existing.edges, fragmentIr(), { x: 500, y: 200 }); + const second = insertFragment(first.nodes, first.edges, fragmentIr(), { x: 800, y: 200 }); + const setA = new Set(first.insertedNodeIds); + for (const id of second.insertedNodeIds) expect(setA.has(id)).toBe(false); + // All ids across the graph are unique. + const allIds = second.nodes.map((n) => n.id); + expect(new Set(allIds).size).toBe(allIds.length); + }); +}); + +describe("fragmentSeamConflicts", () => { + it("flags a merge seam present in both fragment and canvas", () => { + // Canvas containing a merge node. + const canvas = irToFlow( + makeDef({ + version: "v1", + name: "wf", + nodes: [ + { id: "start", kind: "start" }, + { id: "m", kind: "prompt", config: { seam: "merge" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "m", condition: "success" }, + { from: "m", to: "end", condition: "success" }, + ], + }), + ); + expect(fragmentSeamConflicts(fragmentIr(), canvas.nodes)).toEqual(["merge"]); + }); + + it("returns [] when the canvas has no overlapping seam", () => { + const canvas = irToFlow(u8ChainDef()); + expect(fragmentSeamConflicts(fragmentIr(), canvas.nodes)).toEqual([]); + }); + + it("treats the editor 'merge' node kind as the merge seam on the canvas", () => { + // Canvas node has no config.seam but is rendered as a merge node. + const canvas: FlowNode[] = [ + { + id: "x", + type: "merge", + position: { x: 0, y: 0 }, + data: { kind: "merge", label: "Merge boundary" }, + }, + ]; + expect(fragmentSeamConflicts(fragmentIr(), canvas)).toEqual(["merge"]); + }); +}); + +describe("copyIrWithFreshIds", () => { + function v2WithForeach(): WorkflowDefinition["ir"] { + return { + version: "v2", + name: "wf", + columns: [{ id: "in-progress", name: "In Progress", traits: [] }], + nodes: [ + { id: "start", kind: "start", column: "in-progress" }, + { + id: "loop", + kind: "foreach", + column: "in-progress", + config: { + source: "task-steps", + template: { + nodes: [ + { id: "t1", kind: "prompt", config: { prompt: "inner" } }, + { id: "t2", kind: "prompt", config: { prompt: "inner2" } }, + ], + edges: [{ from: "t1", to: "t2", condition: "success" }], + }, + }, + }, + { id: "end", kind: "end", column: "in-progress" }, + ], + edges: [ + { from: "start", to: "loop", condition: "success" }, + { from: "loop", to: "end", condition: "success" }, + ], + }; + } + + it("preserves structure but remaps all node ids; layout keys remapped consistently", () => { + const ir = u8ChainDef().ir; + const layout = { start: { x: 0, y: 0 }, a: { x: 100, y: 0 }, b: { x: 200, y: 0 }, c: { x: 300, y: 0 }, end: { x: 400, y: 0 } }; + const result = copyIrWithFreshIds(ir, layout); + + // Same counts + kinds. + expect(result.ir.nodes).toHaveLength(ir.nodes.length); + expect(result.ir.edges).toHaveLength(ir.edges.length); + expect(result.ir.nodes.map((n) => n.kind)).toEqual(ir.nodes.map((n) => n.kind)); + + // All new ids, disjoint from originals. + const origIds = new Set(ir.nodes.map((n) => n.id)); + for (const n of result.ir.nodes) expect(origIds.has(n.id)).toBe(false); + + // Edges reference only new ids. + const newIds = new Set(result.ir.nodes.map((n) => n.id)); + for (const e of result.ir.edges) { + expect(newIds.has(e.from)).toBe(true); + expect(newIds.has(e.to)).toBe(true); + } + + // Layout keys remapped consistently: same value set, all keys are new ids. + expect(Object.keys(result.layout)).toHaveLength(Object.keys(layout).length); + for (const key of Object.keys(result.layout)) expect(newIds.has(key)).toBe(true); + + // Original inputs untouched. + expect(ir.nodes[0].id).toBe("start"); + expect(layout.start).toEqual({ x: 0, y: 0 }); + + // The copy is a valid IR (value import works under the test-runner alias). + const parsed = parseWorkflowIr(result.ir); + expect(parsed.nodes).toHaveLength(ir.nodes.length); + }); + + it("remaps foreach template node ids + edges, preserving columns", () => { + const ir = v2WithForeach(); + const result = copyIrWithFreshIds(ir, {}); + const loop = result.ir.nodes.find((n) => n.kind === "foreach")!; + const template = (loop.config as { template: { nodes: { id: string; kind: string }[]; edges: { from: string; to: string }[] } }).template; + + // Template node ids are remapped (not the originals t1/t2). + expect(template.nodes.map((n) => n.id)).not.toEqual(["t1", "t2"]); + expect(template.nodes.map((n) => n.kind)).toEqual(["prompt", "prompt"]); + + // Template edges reference the remapped template ids. + const tIds = new Set(template.nodes.map((n) => n.id)); + expect(template.edges).toHaveLength(1); + expect(tIds.has(template.edges[0].from)).toBe(true); + expect(tIds.has(template.edges[0].to)).toBe(true); + + // Columns preserved untouched. + expect(result.ir.version).toBe("v2"); + if (result.ir.version === "v2") { + expect(result.ir.columns).toEqual(ir.columns); + // Node columns carried through. + expect(result.ir.nodes.every((n) => n.column === "in-progress")).toBe(true); + } + }); +}); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index ec20ac9aa9..a1a162c542 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -540,6 +540,15 @@ export function newEdgeId(): string { return `e-${Date.now().toString(36)}-${edgeSeq}`; } +let nodeSeq = 0; +/** Allocate a globally-unique node id (mirrors the editor's local newNodeId). The + * fragment-insert / graph-copy helpers (U8) remap every node id through this so + * inserted/copied subgraphs never collide with existing ids. */ +export function newNodeId(): string { + nodeSeq += 1; + return `n-${Date.now().toString(36)}-${nodeSeq}`; +} + /** Result of attempting to build an edge from a React Flow connection. */ export type BuildConnectionResult = | { edge: FlowEdge } @@ -772,3 +781,228 @@ export function emptyWorkflowIr(name: string): WorkflowIr { export function emptyWorkflowLayout(): Record { return { start: { x: 80, y: 140 }, end: { x: 460, y: 140 } }; } + +// ── Fragment insertion + graph copy (U8, R7/R8) ────────────────────────────── +// +// Pure primitives for the template library: inserting a fragment subgraph into +// the live canvas (palette Templates section, U9) and copying a whole workflow +// graph with fresh ids (create-from-template picker, U4/R7). All three return +// new arrays/objects and never mutate their inputs. + +/** Seam markers that participate in the duplicate-seam pre-validation. A canvas + * may host at most one node per seam, so inserting a fragment that carries a + * seam already present on the canvas is rejected. The editor maps the "merge" + * seam to its dedicated "merge" node kind, so a merge node counts as the merge + * seam even without an explicit config.seam. */ +const SEAM_NAMES = new Set(["execute", "review", "merge"]); + +/** Read the seam marker a flow node represents, if any: an explicit + * config.seam, or the "merge" editor kind (which is the merge seam). */ +function flowNodeSeam(node: FlowNode): string | undefined { + if (node.data?.kind === "merge") return "merge"; + const seam = node.data?.config?.seam; + return typeof seam === "string" ? seam : undefined; +} + +/** Read the seam marker an IR node carries via its config.seam. */ +function irNodeSeam(node: WorkflowIrNode): string | undefined { + const seam = node.config?.seam; + return typeof seam === "string" ? seam : undefined; +} + +/** + * Seam names (execute/review/merge) present in BOTH the fragment and the existing + * canvas — i.e. seams that would be duplicated by inserting the fragment. An + * empty result means the fragment is safe to insert. Other seam values + * (planning, step-execute, …) are not pre-validated here (only the tracked + * execute/review/merge seams are single-instance on the canvas). + */ +export function fragmentSeamConflicts( + fragmentIr: WorkflowIr, + nodes: FlowNode[], +): string[] { + const canvasSeams = new Set(); + for (const n of nodes) { + const seam = flowNodeSeam(n); + if (seam && SEAM_NAMES.has(seam)) canvasSeams.add(seam); + } + const conflicts: string[] = []; + const seen = new Set(); + for (const node of fragmentIr.nodes) { + const seam = irNodeSeam(node); + if (seam && SEAM_NAMES.has(seam) && canvasSeams.has(seam) && !seen.has(seam)) { + seen.add(seam); + conflicts.push(seam); + } + } + return conflicts; +} + +/** Build a React Flow node from a single IR node at an absolute position — the + * same mapping irToFlow applies (kind→type via editorKind, data {kind,label, + * config}, deletable). foreach template bodies are remapped by the caller; this + * carries config (including any template) through verbatim. */ +function irNodeToFlowNode( + node: WorkflowIrNode, + id: string, + position: { x: number; y: number }, +): FlowNode { + const kind = editorKind(node); + return { + id, + type: kind, + position, + data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) } }, + deletable: node.kind !== "start" && node.kind !== "end", + }; +} + +/** + * Insert a fragment subgraph into a flow graph near `position`. + * + * The fragment's `start`/`end` nodes (and every edge incident to them) are + * stripped; each remaining fragment node id is remapped to a fresh newNodeId() + * and the fragment's internal edges are rewired to those ids with fresh edge + * ids, preserving condition/kind. Node config and kind are preserved. Inserted + * nodes are laid out relative to `position` using the fragment's persisted + * layout when present, else simple horizontal x-spacing. + * + * Returns NEW arrays plus the ids of the inserted (remapped) nodes; inputs are + * never mutated. + */ +export function insertFragment( + nodes: FlowNode[], + edges: FlowEdge[], + fragmentIr: WorkflowIr, + position: { x: number; y: number }, + layout?: Record, +): { + nodes: FlowNode[]; + edges: FlowEdge[]; + insertedNodeIds: string[]; +} { + // Drop structural start/end; everything else is a real fragment node. + const bodyNodes = fragmentIr.nodes.filter((n) => n.kind !== "start" && n.kind !== "end"); + const droppedIds = new Set( + fragmentIr.nodes.filter((n) => n.kind === "start" || n.kind === "end").map((n) => n.id), + ); + + // Remap every surviving fragment id to a fresh id. + const idMap = new Map(); + for (const n of bodyNodes) idMap.set(n.id, newNodeId()); + + // Anchor the fragment's layout origin so nodes land near `position`. When the + // fragment ships layout, preserve relative offsets; otherwise space the nodes + // horizontally. + const placed = bodyNodes.map((node) => layout?.[node.id]).filter((p): p is { x: number; y: number } => !!p); + const minX = placed.length ? Math.min(...placed.map((p) => p.x)) : 0; + const minY = placed.length ? Math.min(...placed.map((p) => p.y)) : 0; + + const insertedNodeIds: string[] = []; + const newNodes = bodyNodes.map((node, index): FlowNode => { + const id = idMap.get(node.id)!; + insertedNodeIds.push(id); + const fromLayout = layout?.[node.id]; + const pos = fromLayout + ? { x: position.x + (fromLayout.x - minX), y: position.y + (fromLayout.y - minY) } + : { x: position.x + index * 180, y: position.y }; + return irNodeToFlowNode(node, id, pos); + }); + + // Rewire only the fragment's INTERNAL edges (both endpoints survived). Edges + // touching a stripped start/end node are dropped. + const newEdges: FlowEdge[] = fragmentIr.edges + .filter((e) => !droppedIds.has(e.from) && !droppedIds.has(e.to)) + .filter((e) => idMap.has(e.from) && idMap.has(e.to)) + .map((edge, index) => { + const flow = irEdgeToFlow(edge, index); + return { + ...flow, + id: newEdgeId(), + source: idMap.get(edge.from)!, + target: idMap.get(edge.to)!, + }; + }); + + return { + nodes: [...nodes, ...newNodes], + edges: [...edges, ...newEdges], + insertedNodeIds, + }; +} + +/** Remap a foreach template's internal node ids + edges to fresh ids. Returns a + * new template object; the original is untouched. Template-local ids are scoped + * to the template, so a fresh local id space suffices (and keeps config compact + * rather than reusing global ids). */ +function copyForeachTemplate(template: { + nodes: WorkflowIrNode[]; + edges: WorkflowIrEdge[]; +}): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } { + const innerMap = new Map(); + for (const n of template.nodes) innerMap.set(n.id, newNodeId()); + const nodes = template.nodes.map((n) => copyIrNode(n, innerMap.get(n.id)!)); + const edges = template.edges.map((e) => ({ + ...e, + from: innerMap.get(e.from) ?? e.from, + to: innerMap.get(e.to) ?? e.to, + })); + return { nodes, edges }; +} + +/** Deep-ish copy of an IR node under a new id, recursing into a foreach + * template's internal node references so they remain self-consistent. */ +function copyIrNode(node: WorkflowIrNode, newId: string): WorkflowIrNode { + const config = node.config ? { ...node.config } : undefined; + const foreach = foreachConfigOf(node); + if (foreach && config) { + config.template = copyForeachTemplate(foreach.template); + } + const copy: WorkflowIrNode = { id: newId, kind: node.kind }; + if (node.column !== undefined) copy.column = node.column; + if (config) copy.config = config; + return copy; +} + +/** + * Full-graph copy with fresh ids (R7): every top-level node id is remapped to a + * fresh id, edges are rewired, and the layout map's keys are remapped to match. + * v2 columns/fields/artifacts are preserved untouched (they hold no node id + * references). foreach template bodies have their internal node ids + edges + * remapped consistently too. Returns a NEW ir + layout; inputs are not mutated. + */ +export function copyIrWithFreshIds( + ir: WorkflowIr, + layout: Record, +): { ir: WorkflowIr; layout: Record } { + const idMap = new Map(); + for (const n of ir.nodes) idMap.set(n.id, newNodeId()); + + const nodes = ir.nodes.map((n) => copyIrNode(n, idMap.get(n.id)!)); + const edges = ir.edges.map((e) => ({ + ...e, + from: idMap.get(e.from) ?? e.from, + to: idMap.get(e.to) ?? e.to, + })); + + // Remap layout keys for top-level nodes; leave any unrelated keys as-is. + const newLayout: Record = {}; + for (const [key, pos] of Object.entries(layout)) { + const mapped = idMap.get(key); + newLayout[mapped ?? key] = { ...pos }; + } + + let copied: WorkflowIr; + if (isV2(ir)) { + const v2: WorkflowIrV2 = { + ...ir, + nodes, + edges, + columns: ir.columns.map((c) => ({ ...c, traits: c.traits.map((t) => ({ ...t })) })), + }; + copied = v2; + } else { + copied = { ...ir, nodes, edges }; + } + return { ir: copied, layout: newLayout }; +}