feat(dashboard): fragment insertion, seam-conflict, and graph-copy helpers
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<WorkflowFlowNodeData>[] = [
|
||||
{
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, { x: number; y: number }> {
|
||||
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<string>(["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<WorkflowFlowNodeData>): 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<WorkflowFlowNodeData>[],
|
||||
): string[] {
|
||||
const canvasSeams = new Set<string>();
|
||||
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<string>();
|
||||
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<WorkflowFlowNodeData> {
|
||||
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<WorkflowFlowNodeData>[],
|
||||
edges: FlowEdge[],
|
||||
fragmentIr: WorkflowIr,
|
||||
position: { x: number; y: number },
|
||||
layout?: Record<string, { x: number; y: number }>,
|
||||
): {
|
||||
nodes: FlowNode<WorkflowFlowNodeData>[];
|
||||
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<string, string>();
|
||||
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<WorkflowFlowNodeData> => {
|
||||
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<string, string>();
|
||||
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<string, { x: number; y: number }>,
|
||||
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
|
||||
const idMap = new Map<string, string>();
|
||||
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<string, { x: number; y: number }> = {};
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user