feat(dashboard): fragment insertion, seam-conflict, and graph-copy helpers

This commit is contained in:
gsxdsm
2026-06-04 23:02:50 -07:00
parent 8a5fa66def
commit 7bbb8b650a
3 changed files with 483 additions and 0 deletions

View File

@@ -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,

View File

@@ -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);
}
});
});

View File

@@ -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 };
}