@bian-womp/spark-workbench 0.2.78 → 0.2.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/lib/cjs/index.cjs +346 -283
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/adapters/cli/index.d.ts +1 -1
  4. package/lib/cjs/src/adapters/cli/index.d.ts.map +1 -1
  5. package/lib/cjs/src/core/AbstractWorkbench.d.ts +1 -1
  6. package/lib/cjs/src/core/AbstractWorkbench.d.ts.map +1 -1
  7. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +45 -2
  8. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  9. package/lib/cjs/src/core/contracts.d.ts +4 -2
  10. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  11. package/lib/cjs/src/misc/Inspector.d.ts.map +1 -1
  12. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  14. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +0 -1
  15. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  16. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  17. package/lib/cjs/src/misc/context-menu/ContextMenuHelpers.d.ts.map +1 -1
  18. package/lib/cjs/src/misc/hooks.d.ts.map +1 -1
  19. package/lib/cjs/src/misc/load.d.ts.map +1 -1
  20. package/lib/esm/index.js +347 -284
  21. package/lib/esm/index.js.map +1 -1
  22. package/lib/esm/src/adapters/cli/index.d.ts +1 -1
  23. package/lib/esm/src/adapters/cli/index.d.ts.map +1 -1
  24. package/lib/esm/src/core/AbstractWorkbench.d.ts +1 -1
  25. package/lib/esm/src/core/AbstractWorkbench.d.ts.map +1 -1
  26. package/lib/esm/src/core/InMemoryWorkbench.d.ts +45 -2
  27. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  28. package/lib/esm/src/core/contracts.d.ts +4 -2
  29. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  30. package/lib/esm/src/misc/Inspector.d.ts.map +1 -1
  31. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  32. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  33. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +0 -1
  34. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  35. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  36. package/lib/esm/src/misc/context-menu/ContextMenuHelpers.d.ts.map +1 -1
  37. package/lib/esm/src/misc/hooks.d.ts.map +1 -1
  38. package/lib/esm/src/misc/load.d.ts.map +1 -1
  39. package/package.json +4 -4
package/lib/cjs/index.cjs CHANGED
@@ -123,7 +123,7 @@ class AbstractWorkbench {
123
123
  class InMemoryWorkbench extends AbstractWorkbench {
124
124
  constructor() {
125
125
  super(...arguments);
126
- this.def = { nodes: [], edges: [] };
126
+ this._def = { nodes: [], edges: [] };
127
127
  this.listeners = new Map();
128
128
  this.positions = {};
129
129
  this.selection = {
@@ -135,40 +135,40 @@ class InMemoryWorkbench extends AbstractWorkbench {
135
135
  this.historyState = undefined;
136
136
  this.copiedData = null;
137
137
  }
138
+ get def() {
139
+ return this._def;
140
+ }
138
141
  setRegistry(registry) {
139
142
  this.registry = registry;
140
143
  }
141
144
  async load(def) {
142
- this.def = { nodes: [...def.nodes], edges: [...def.edges] };
145
+ this._def = { nodes: [...def.nodes], edges: [...def.edges] };
143
146
  if (this.layout) {
144
- const { positions } = await this.layout.layout(this.def);
147
+ const { positions } = await this.layout.layout(this._def);
145
148
  this.positions = positions;
146
149
  }
147
- const defNodeIds = new Set(this.def.nodes.map((n) => n.nodeId));
148
- const defEdgeIds = new Set(this.def.edges.map((e) => e.id));
150
+ const defNodeIds = new Set(this._def.nodes.map((n) => n.nodeId));
151
+ const defEdgeIds = new Set(this._def.edges.map((e) => e.id));
149
152
  const filteredPositions = Object.fromEntries(Object.entries(this.positions).filter(([id]) => defNodeIds.has(id)));
150
153
  const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
151
154
  const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
152
155
  this.positions = filteredPositions;
153
156
  this.selection = { nodes: filteredNodes, edges: filteredEdges };
154
- this.emit("graphChanged", { def: this.def });
157
+ this.emit("graphChanged", { def: this._def });
155
158
  this.refreshValidation();
156
159
  }
157
- export() {
158
- return this.def;
159
- }
160
160
  refreshValidation() {
161
161
  this.emit("validationChanged", this.validate());
162
162
  }
163
163
  validate() {
164
164
  if (this.registry) {
165
165
  const builder = new sparkGraph.GraphBuilder(this.registry);
166
- const report = builder.validate(this.def);
166
+ const report = builder.validate(this._def);
167
167
  return report;
168
168
  }
169
169
  const issues = [];
170
170
  const nodeIds = new Set();
171
- for (const n of this.def.nodes) {
171
+ for (const n of this._def.nodes) {
172
172
  if (nodeIds.has(n.nodeId)) {
173
173
  issues.push({
174
174
  level: "error",
@@ -180,7 +180,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
180
180
  nodeIds.add(n.nodeId);
181
181
  }
182
182
  const edgeIds = new Set();
183
- for (const e of this.def.edges) {
183
+ for (const e of this._def.edges) {
184
184
  if (edgeIds.has(e.id)) {
185
185
  issues.push({
186
186
  level: "error",
@@ -195,15 +195,15 @@ class InMemoryWorkbench extends AbstractWorkbench {
195
195
  }
196
196
  setInputs(nodeId, inputs, options) {
197
197
  this.emit("graphChanged", {
198
- def: this.def,
198
+ def: this._def,
199
199
  change: { type: "setInputs", nodeId, inputs },
200
200
  ...options,
201
201
  });
202
202
  }
203
203
  addNode(node, options) {
204
204
  const id = node.nodeId ??
205
- this.genId("n", new Set(this.def.nodes.map((n) => n.nodeId)));
206
- this.def.nodes.push({
205
+ this.genId("n", new Set(this._def.nodes.map((n) => n.nodeId)));
206
+ this._def.nodes.push({
207
207
  nodeId: id,
208
208
  typeId: node.typeId,
209
209
  params: node.params,
@@ -212,7 +212,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
212
212
  if (node.position)
213
213
  this.positions[id] = node.position;
214
214
  this.emit("graphChanged", {
215
- def: this.def,
215
+ def: this._def,
216
216
  change: {
217
217
  type: "addNode",
218
218
  nodeId: id,
@@ -225,26 +225,26 @@ class InMemoryWorkbench extends AbstractWorkbench {
225
225
  return id;
226
226
  }
227
227
  removeNode(nodeId, options) {
228
- this.def.nodes = this.def.nodes.filter((n) => n.nodeId !== nodeId);
229
- this.def.edges = this.def.edges.filter((e) => e.source.nodeId !== nodeId && e.target.nodeId !== nodeId);
228
+ this._def.nodes = this._def.nodes.filter((n) => n.nodeId !== nodeId);
229
+ this._def.edges = this._def.edges.filter((e) => e.source.nodeId !== nodeId && e.target.nodeId !== nodeId);
230
230
  delete this.positions[nodeId];
231
231
  this.emit("graphChanged", {
232
- def: this.def,
232
+ def: this._def,
233
233
  change: { type: "removeNode", nodeId },
234
234
  ...options,
235
235
  });
236
236
  this.refreshValidation();
237
237
  }
238
238
  connect(edge, options) {
239
- const id = edge.id ?? this.genId("e", new Set(this.def.edges.map((e) => e.id)));
240
- this.def.edges.push({
239
+ const id = edge.id ?? this.genId("e", new Set(this._def.edges.map((e) => e.id)));
240
+ this._def.edges.push({
241
241
  id,
242
242
  source: { ...edge.source },
243
243
  target: { ...edge.target },
244
244
  typeId: edge.typeId,
245
245
  });
246
246
  this.emit("graphChanged", {
247
- def: this.def,
247
+ def: this._def,
248
248
  change: { type: "connect", edgeId: id },
249
249
  ...options,
250
250
  });
@@ -252,16 +252,16 @@ class InMemoryWorkbench extends AbstractWorkbench {
252
252
  return id;
253
253
  }
254
254
  disconnect(edgeId, options) {
255
- this.def.edges = this.def.edges.filter((e) => e.id !== edgeId);
255
+ this._def.edges = this._def.edges.filter((e) => e.id !== edgeId);
256
256
  this.emit("graphChanged", {
257
- def: this.def,
257
+ def: this._def,
258
258
  change: { type: "disconnect", edgeId },
259
259
  ...options,
260
260
  });
261
261
  this.emit("validationChanged", this.validate());
262
262
  }
263
263
  updateEdgeType(edgeId, typeId) {
264
- const e = this.def.edges.find((x) => x.id === edgeId);
264
+ const e = this._def.edges.find((x) => x.id === edgeId);
265
265
  if (!e)
266
266
  return;
267
267
  if (!typeId)
@@ -269,18 +269,18 @@ class InMemoryWorkbench extends AbstractWorkbench {
269
269
  else
270
270
  e.typeId = typeId;
271
271
  this.emit("graphChanged", {
272
- def: this.def,
272
+ def: this._def,
273
273
  change: { type: "updateEdgeType", edgeId, typeId },
274
274
  });
275
275
  this.refreshValidation();
276
276
  }
277
277
  updateParams(nodeId, params) {
278
- const n = this.def.nodes.find((n) => n.nodeId === nodeId);
278
+ const n = this._def.nodes.find((n) => n.nodeId === nodeId);
279
279
  if (!n)
280
280
  return;
281
281
  n.params = { ...(n.params ?? {}), ...params };
282
282
  this.emit("graphChanged", {
283
- def: this.def,
283
+ def: this._def,
284
284
  change: { type: "updateParams", nodeId },
285
285
  });
286
286
  }
@@ -288,7 +288,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
288
288
  setPositions(map, options) {
289
289
  this.positions = { ...this.positions, ...map };
290
290
  this.emit("graphUiChanged", {
291
- def: this.def,
291
+ def: this._def,
292
292
  change: { type: "moveNodes" },
293
293
  ...options,
294
294
  });
@@ -296,17 +296,20 @@ class InMemoryWorkbench extends AbstractWorkbench {
296
296
  getPositions() {
297
297
  return { ...this.positions };
298
298
  }
299
- setSelection(sel, options) {
299
+ setSelectionInternal(sel, options) {
300
300
  if (lod.isEqual(this.selection, sel))
301
301
  return;
302
302
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
303
303
  this.emit("selectionChanged", this.selection);
304
304
  this.emit("graphUiChanged", {
305
- def: this.def,
305
+ def: this._def,
306
306
  change: { type: "selection" },
307
307
  ...options,
308
308
  });
309
309
  }
310
+ setSelection(sel, options) {
311
+ this.setSelectionInternal(sel, options);
312
+ }
310
313
  getSelection() {
311
314
  return {
312
315
  nodes: [...this.selection.nodes],
@@ -327,14 +330,14 @@ class InMemoryWorkbench extends AbstractWorkbench {
327
330
  this.disconnect(edgeId);
328
331
  }
329
332
  // Clear selection
330
- this.setSelection({ nodes: [], edges: [] }, options);
333
+ this.setSelectionInternal({ nodes: [], edges: [] }, options);
331
334
  }
332
335
  setViewport(viewport) {
333
336
  if (lod.isEqual(this.viewport, viewport))
334
337
  return;
335
338
  this.viewport = { ...viewport };
336
339
  this.emit("graphUiChanged", {
337
- def: this.def,
340
+ def: this._def,
338
341
  change: { type: "viewport" },
339
342
  });
340
343
  }
@@ -342,8 +345,8 @@ class InMemoryWorkbench extends AbstractWorkbench {
342
345
  return this.viewport ? { ...this.viewport } : null;
343
346
  }
344
347
  getUIState() {
345
- const defNodeIds = new Set(this.def.nodes.map((n) => n.nodeId));
346
- const defEdgeIds = new Set(this.def.edges.map((e) => e.id));
348
+ const defNodeIds = new Set(this._def.nodes.map((n) => n.nodeId));
349
+ const defEdgeIds = new Set(this._def.edges.map((e) => e.id));
347
350
  const filteredPositions = Object.fromEntries(Object.entries(this.positions).filter(([id]) => defNodeIds.has(id)));
348
351
  const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
349
352
  const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
@@ -382,6 +385,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
382
385
  }
383
386
  setRuntimeState(runtime) {
384
387
  this.runtimeState = runtime ? { ...runtime } : null;
388
+ this.emit("runtimeMetadataChanged", {});
385
389
  }
386
390
  getHistory() {
387
391
  return this.historyState;
@@ -398,6 +402,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
398
402
  const nodeMeta = current.nodes[nodeId] ?? {};
399
403
  const updated = updater({ ...nodeMeta });
400
404
  this.runtimeState = { nodes: { ...current.nodes, [nodeId]: updated } };
405
+ this.emit("runtimeMetadataChanged", { nodeId });
401
406
  }
402
407
  on(event, handler) {
403
408
  if (!this.listeners.has(event))
@@ -422,11 +427,10 @@ class InMemoryWorkbench extends AbstractWorkbench {
422
427
  const selection = this.getSelection();
423
428
  if (selection.nodes.length === 0)
424
429
  return null;
425
- const def = this.export();
426
430
  const positions = this.getPositions();
427
431
  const selectedNodeSet = new Set(selection.nodes);
428
432
  // Collect nodes to copy
429
- const nodesToCopy = def.nodes.filter((n) => selectedNodeSet.has(n.nodeId));
433
+ const nodesToCopy = this.def.nodes.filter((n) => selectedNodeSet.has(n.nodeId));
430
434
  if (nodesToCopy.length === 0)
431
435
  return null;
432
436
  // Calculate bounds
@@ -450,12 +454,12 @@ class InMemoryWorkbench extends AbstractWorkbench {
450
454
  const centerY = (bounds.minY + bounds.maxY) / 2;
451
455
  // Get inputs for each node
452
456
  // Include values from inbound edges if those edges are selected
453
- const allInputs = runner.getInputs(def);
457
+ const allInputs = runner.getInputs(this.def);
454
458
  const selectedEdgeSet = new Set(selection.edges);
455
459
  const copiedNodes = nodesToCopy.map((node) => {
456
460
  const pos = positions[node.nodeId] || { x: 0, y: 0 };
457
461
  // Get all inbound edges for this node
458
- const inboundEdges = def.edges.filter((e) => e.target.nodeId === node.nodeId);
462
+ const inboundEdges = this.def.edges.filter((e) => e.target.nodeId === node.nodeId);
459
463
  // Build set of handles that have inbound edges
460
464
  // But only exclude handles whose edges are NOT selected
461
465
  const inboundHandlesToExclude = new Set(inboundEdges
@@ -479,7 +483,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
479
483
  };
480
484
  });
481
485
  // Collect edges between copied nodes
482
- const copiedEdges = def.edges
486
+ const copiedEdges = this.def.edges
483
487
  .filter((edge) => {
484
488
  return (selectedNodeSet.has(edge.source.nodeId) &&
485
489
  selectedNodeSet.has(edge.target.nodeId));
@@ -497,6 +501,193 @@ class InMemoryWorkbench extends AbstractWorkbench {
497
501
  bounds,
498
502
  };
499
503
  }
504
+ /**
505
+ * Duplicate all selected nodes.
506
+ * Returns the list of newly created node IDs.
507
+ * Each duplicated node is offset by 24px in both x and y directions.
508
+ * Copies inputs without bindings and uses copyOutputsFrom to copy outputs.
509
+ */
510
+ duplicateSelection(runner, options) {
511
+ const selection = this.getSelection();
512
+ if (selection.nodes.length === 0)
513
+ return [];
514
+ const positions = this.getPositions();
515
+ const newNodes = [];
516
+ // Get inputs without bindings (literal values only)
517
+ const allInputs = runner.getInputs(this.def) || {};
518
+ // Duplicate each selected node
519
+ for (const nodeId of selection.nodes) {
520
+ const n = this.def.nodes.find((n) => n.nodeId === nodeId);
521
+ if (!n)
522
+ continue;
523
+ const pos = positions[nodeId] || { x: 0, y: 0 };
524
+ const inboundHandles = new Set(this.def.edges
525
+ .filter((e) => e.target.nodeId === nodeId)
526
+ .map((e) => e.target.handle));
527
+ const inputsWithoutBindings = Object.fromEntries(Object.entries(allInputs).filter(([handle]) => !inboundHandles.has(handle)));
528
+ const newNodeId = this.addNode({
529
+ typeId: n.typeId,
530
+ params: n.params,
531
+ position: { x: pos.x + 24, y: pos.y + 24 },
532
+ resolvedHandles: n.resolvedHandles,
533
+ }, {
534
+ inputs: inputsWithoutBindings,
535
+ copyOutputsFrom: nodeId,
536
+ dry: true,
537
+ });
538
+ newNodes.push(newNodeId);
539
+ }
540
+ // Select all newly duplicated nodes
541
+ if (newNodes.length > 0) {
542
+ this.setSelectionInternal({ nodes: newNodes, edges: [] }, options || { commit: true, reason: "duplicate-selection" });
543
+ }
544
+ return newNodes;
545
+ }
546
+ /**
547
+ * Bake an output value from a node into a new node.
548
+ * Creates a new node based on the output type's bakeTarget configuration.
549
+ * Returns the ID of the last created node (or undefined if none created).
550
+ */
551
+ async bake(registry, runner, outputValue, outputTypeId, nodePosition, options) {
552
+ try {
553
+ if (!outputTypeId || outputValue === undefined)
554
+ return undefined;
555
+ const unwrap = (v) => sparkGraph.isTypedOutput(v) ? sparkGraph.getTypedOutputValue(v) : v;
556
+ const coerceIfNeeded = async (fromType, toType, value) => {
557
+ if (!toType || toType === fromType || !runner?.coerce)
558
+ return value;
559
+ try {
560
+ return await runner.coerce(fromType, toType, value);
561
+ }
562
+ catch {
563
+ return value;
564
+ }
565
+ };
566
+ const pos = nodePosition;
567
+ const isArray = outputTypeId.endsWith("[]");
568
+ const baseTypeId = isArray ? outputTypeId.slice(0, -2) : outputTypeId;
569
+ const tArr = isArray ? registry.types.get(outputTypeId) : undefined;
570
+ const tElem = registry.types.get(baseTypeId);
571
+ const singleTarget = !isArray ? tElem?.bakeTarget : undefined;
572
+ const arrTarget = isArray ? tArr?.bakeTarget : undefined;
573
+ const elemTarget = isArray ? tElem?.bakeTarget : undefined;
574
+ let newNodeId;
575
+ if (singleTarget) {
576
+ const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
577
+ const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
578
+ const coerced = await coerceIfNeeded(outputTypeId, inType, unwrap(outputValue));
579
+ newNodeId = this.addNode({
580
+ typeId: singleTarget.nodeTypeId,
581
+ position: { x: pos.x + 180, y: pos.y },
582
+ }, { inputs: { [singleTarget.inputHandle]: coerced } });
583
+ }
584
+ else if (isArray && arrTarget) {
585
+ const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
586
+ const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
587
+ const coerced = await coerceIfNeeded(outputTypeId, inType, unwrap(outputValue));
588
+ newNodeId = this.addNode({
589
+ typeId: arrTarget.nodeTypeId,
590
+ position: { x: pos.x + 180, y: pos.y },
591
+ }, { inputs: { [arrTarget.inputHandle]: coerced } });
592
+ }
593
+ else if (isArray && elemTarget) {
594
+ const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
595
+ const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, elemTarget.inputHandle);
596
+ const src = unwrap(outputValue);
597
+ const items = Array.isArray(src) ? src : [src];
598
+ const coercedItems = await Promise.all(items.map((v) => coerceIfNeeded(baseTypeId, inType, v)));
599
+ const COLS = 4;
600
+ const DX = 180;
601
+ const DY = 160;
602
+ for (let idx = 0; idx < coercedItems.length; idx++) {
603
+ const col = idx % COLS;
604
+ const row = Math.floor(idx / COLS);
605
+ newNodeId = this.addNode({
606
+ typeId: elemTarget.nodeTypeId,
607
+ position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
608
+ }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
609
+ }
610
+ }
611
+ if (newNodeId) {
612
+ this.setSelectionInternal({ nodes: [newNodeId], edges: [] }, options || { commit: true, reason: "bake" });
613
+ }
614
+ return newNodeId;
615
+ }
616
+ catch {
617
+ return undefined;
618
+ }
619
+ }
620
+ /**
621
+ * Duplicate a single node.
622
+ * Returns the ID of the newly created node.
623
+ * The duplicated node is offset by 24px in both x and y directions.
624
+ * Copies inputs without bindings and uses copyOutputsFrom to copy outputs.
625
+ */
626
+ duplicateNode(nodeId, runner, options) {
627
+ const n = this.def.nodes.find((n) => n.nodeId === nodeId);
628
+ if (!n)
629
+ return undefined;
630
+ const pos = this.getPositions()[nodeId] || { x: 0, y: 0 };
631
+ // Get inputs without bindings (literal values only)
632
+ const allInputs = runner.getInputs(this.def)[nodeId] || {};
633
+ const inboundHandles = new Set(this.def.edges
634
+ .filter((e) => e.target.nodeId === nodeId)
635
+ .map((e) => e.target.handle));
636
+ const inputsWithoutBindings = Object.fromEntries(Object.entries(allInputs).filter(([handle]) => !inboundHandles.has(handle)));
637
+ const newNodeId = this.addNode({
638
+ typeId: n.typeId,
639
+ params: n.params,
640
+ position: { x: pos.x + 24, y: pos.y + 24 },
641
+ resolvedHandles: n.resolvedHandles,
642
+ }, {
643
+ inputs: inputsWithoutBindings,
644
+ copyOutputsFrom: nodeId,
645
+ dry: true,
646
+ });
647
+ // Select the newly duplicated node
648
+ this.setSelectionInternal({ nodes: [newNodeId], edges: [] }, options || { commit: true, reason: "duplicate-node" });
649
+ return newNodeId;
650
+ }
651
+ /**
652
+ * Duplicate a node and all its incoming edges.
653
+ * Returns the ID of the newly created node.
654
+ * The duplicated node is offset by 24px in both x and y directions.
655
+ * All incoming edges are duplicated to point to the new node.
656
+ */
657
+ duplicateNodeWithEdges(nodeId, runner, options) {
658
+ const n = this.def.nodes.find((n) => n.nodeId === nodeId);
659
+ if (!n)
660
+ return undefined;
661
+ const pos = this.getPositions()[nodeId] || { x: 0, y: 0 };
662
+ // Get all inputs (including those with bindings, since edges will be duplicated)
663
+ const inputs = runner.getInputs(this.def)[nodeId] || {};
664
+ // Add the duplicated node
665
+ const newNodeId = this.addNode({
666
+ typeId: n.typeId,
667
+ params: n.params,
668
+ position: { x: pos.x + 24, y: pos.y + 24 },
669
+ resolvedHandles: n.resolvedHandles,
670
+ }, {
671
+ inputs,
672
+ copyOutputsFrom: nodeId,
673
+ dry: true,
674
+ });
675
+ // Find all incoming edges (edges where target is the original node)
676
+ const incomingEdges = this.def.edges.filter((e) => e.target.nodeId === nodeId);
677
+ // Duplicate each incoming edge to point to the new node
678
+ for (const edge of incomingEdges) {
679
+ this.connect({
680
+ source: edge.source, // Keep the same source
681
+ target: { nodeId: newNodeId, handle: edge.target.handle }, // Point to new node
682
+ typeId: edge.typeId,
683
+ }, { dry: true });
684
+ }
685
+ // Select the newly duplicated node
686
+ if (newNodeId) {
687
+ this.setSelectionInternal({ nodes: [newNodeId], edges: [] }, options || { commit: true, reason: "duplicate-node-with-edges" });
688
+ }
689
+ return newNodeId;
690
+ }
500
691
  /**
501
692
  * Paste copied graph data at the specified center position.
502
693
  * Returns the mapping from original node IDs to new node IDs.
@@ -542,7 +733,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
542
733
  }
543
734
  }
544
735
  // Select the newly pasted nodes
545
- this.setSelection({ nodes: Array.from(nodeIdMap.values()), edges: edgeIds }, options);
736
+ this.setSelectionInternal({ nodes: Array.from(nodeIdMap.values()), edges: edgeIds }, options);
546
737
  return { nodeIdMap, edgeIds };
547
738
  }
548
739
  /**
@@ -567,8 +758,8 @@ class CLIWorkbench {
567
758
  async load(def) {
568
759
  await this.wb.load(def);
569
760
  }
570
- print(def, options) {
571
- const d = def ?? this.wb.export();
761
+ print(options) {
762
+ const d = this.wb.def;
572
763
  const detail = !!options?.detail;
573
764
  const lines = [];
574
765
  lines.push(`Nodes (${d.nodes.length})`);
@@ -1937,8 +2128,7 @@ function useWorkbenchBridge(wb) {
1937
2128
  if (!params.sourceHandle || !params.targetHandle)
1938
2129
  return;
1939
2130
  // Prevent duplicate edges between the same endpoints
1940
- const def = wb.export();
1941
- const exists = def.edges.some((e) => e.source.nodeId === params.source &&
2131
+ const exists = wb.def.edges.some((e) => e.source.nodeId === params.source &&
1942
2132
  e.source.handle === params.sourceHandle &&
1943
2133
  e.target.nodeId === params.target &&
1944
2134
  e.target.handle === params.targetHandle);
@@ -1988,7 +2178,10 @@ function useWorkbenchBridge(wb) {
1988
2178
  }
1989
2179
  }
1990
2180
  if (selectionChanged) {
1991
- wb.setSelection({ nodes: Array.from(nextNodeIds), edges: current.edges }, { commit: !(Object.keys(positions).length && commit) });
2181
+ wb.setSelection({
2182
+ nodes: Array.from(nextNodeIds),
2183
+ edges: current.edges,
2184
+ });
1992
2185
  }
1993
2186
  if (Object.keys(positions).length > 0) {
1994
2187
  wb.setPositions(positions, { commit });
@@ -2023,7 +2216,10 @@ function useWorkbenchBridge(wb) {
2023
2216
  }
2024
2217
  }
2025
2218
  if (selectionChanged) {
2026
- wb.setSelection({ nodes: current.nodes, edges: Array.from(nextEdgeIds) }, { commit: true });
2219
+ wb.setSelection({
2220
+ nodes: current.nodes,
2221
+ edges: Array.from(nextEdgeIds),
2222
+ });
2027
2223
  }
2028
2224
  }, [wb]);
2029
2225
  const onNodesDelete = React.useCallback((nodes) => {
@@ -2469,7 +2665,6 @@ function isSnapshotPayload(parsed) {
2469
2665
  }
2470
2666
  async function download(wb, runner) {
2471
2667
  try {
2472
- const def = wb.export();
2473
2668
  const fullUiState = wb.getUIState();
2474
2669
  const uiState = excludeViewportFromUIState(fullUiState);
2475
2670
  const runtimeState = wb.getRuntimeState();
@@ -2478,7 +2673,7 @@ async function download(wb, runner) {
2478
2673
  const fullSnapshot = await runner.snapshotFull();
2479
2674
  snapshot = {
2480
2675
  ...fullSnapshot,
2481
- def,
2676
+ def: wb.def,
2482
2677
  extData: {
2483
2678
  ...(fullSnapshot.extData || {}),
2484
2679
  ui: Object.keys(uiState || {}).length > 0 ? uiState : undefined,
@@ -2487,9 +2682,9 @@ async function download(wb, runner) {
2487
2682
  };
2488
2683
  }
2489
2684
  else {
2490
- const inputs = runner.getInputs(def);
2685
+ const inputs = runner.getInputs(wb.def);
2491
2686
  snapshot = {
2492
- def,
2687
+ def: wb.def,
2493
2688
  inputs,
2494
2689
  outputs: {},
2495
2690
  environment: {},
@@ -2528,7 +2723,7 @@ async function upload(parsed, wb, runner) {
2528
2723
  }
2529
2724
  if (runner.isRunning()) {
2530
2725
  await runner.applySnapshotFull({
2531
- def,
2726
+ def: wb.def,
2532
2727
  environment,
2533
2728
  inputs,
2534
2729
  outputs,
@@ -2536,7 +2731,7 @@ async function upload(parsed, wb, runner) {
2536
2731
  });
2537
2732
  }
2538
2733
  else {
2539
- runner.build(wb.export());
2734
+ runner.build(wb.def);
2540
2735
  if (inputs && typeof inputs === "object") {
2541
2736
  for (const [nodeId, map] of Object.entries(inputs)) {
2542
2737
  runner.setInputs(nodeId, map, { dry: true });
@@ -2632,14 +2827,13 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2632
2827
  const versionTick = useWorkbenchVersionTick(runner);
2633
2828
  const valuesTick = versionTick + graphTick + graphUiTick;
2634
2829
  // Def and IO values
2635
- const def = wb.export();
2636
- const inputsMap = React.useMemo(() => runner.getInputs(def), [runner, def, valuesTick]);
2637
- const inputDefaultsMap = React.useMemo(() => runner.getInputDefaults(def), [runner, def, valuesTick]);
2638
- const outputsMap = React.useMemo(() => runner.getOutputs(def), [runner, def, valuesTick]);
2830
+ const inputsMap = React.useMemo(() => runner.getInputs(wb.def), [runner, wb, wb.def, valuesTick]);
2831
+ const inputDefaultsMap = React.useMemo(() => runner.getInputDefaults(wb.def), [runner, wb, wb.def, valuesTick]);
2832
+ const outputsMap = React.useMemo(() => runner.getOutputs(wb.def), [runner, wb, wb.def, valuesTick]);
2639
2833
  const outputTypesMap = React.useMemo(() => {
2640
2834
  const out = {};
2641
2835
  // Local: runtimeTypeId is not stored; derive from typed wrapper in outputsMap
2642
- for (const n of def.nodes) {
2836
+ for (const n of wb.def.nodes) {
2643
2837
  const effectiveHandles = computeEffectiveHandles(n, registry);
2644
2838
  const outputsDecl = effectiveHandles.outputs;
2645
2839
  const handles = Object.keys(outputsDecl);
@@ -2654,14 +2848,14 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2654
2848
  out[n.nodeId] = cur;
2655
2849
  }
2656
2850
  return out;
2657
- }, [def, outputsMap, registry]);
2851
+ }, [wb, wb.def, outputsMap, registry]);
2658
2852
  // Initialize nodes and derive invalidated status from persisted metadata
2659
2853
  React.useEffect(() => {
2660
2854
  const workbenchRuntimeState = wb.getRuntimeState() ?? { nodes: {} };
2661
2855
  setNodeStatus((prev) => {
2662
2856
  const next = { ...prev };
2663
2857
  const metadata = workbenchRuntimeState;
2664
- for (const n of def.nodes) {
2858
+ for (const n of wb.def.nodes) {
2665
2859
  const cur = next[n.nodeId] ?? (next[n.nodeId] = {});
2666
2860
  const nodeMeta = metadata.nodes[n.nodeId];
2667
2861
  const updates = {};
@@ -2683,18 +2877,17 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2683
2877
  }
2684
2878
  return next;
2685
2879
  });
2686
- }, [def, wb]);
2880
+ }, [wb.def, wb]);
2687
2881
  // Auto layout (simple layered layout)
2688
2882
  const runAutoLayout = React.useCallback(() => {
2689
- const cur = wb.export();
2690
2883
  // Build DAG layers by indegree
2691
2884
  const indegree = {};
2692
2885
  const adj = {};
2693
- for (const n of cur.nodes) {
2886
+ for (const n of wb.def.nodes) {
2694
2887
  indegree[n.nodeId] = 0;
2695
2888
  adj[n.nodeId] = [];
2696
2889
  }
2697
- for (const e of cur.edges) {
2890
+ for (const e of wb.def.edges) {
2698
2891
  indegree[e.target.nodeId] = (indegree[e.target.nodeId] ?? 0) + 1;
2699
2892
  adj[e.source.nodeId].push(e.target.nodeId);
2700
2893
  }
@@ -2725,7 +2918,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2725
2918
  let maxWidth = 0;
2726
2919
  const heights = {};
2727
2920
  for (const id of layer) {
2728
- const node = cur.nodes.find((n) => n.nodeId === id);
2921
+ const node = wb.def.nodes.find((n) => n.nodeId === id);
2729
2922
  if (!node)
2730
2923
  continue;
2731
2924
  // Prefer showValues sizing similar to node rendering
@@ -2751,26 +2944,26 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2751
2944
  curX += maxWidth + H_GAP;
2752
2945
  }
2753
2946
  wb.setPositions(pos, { commit: true, reason: "auto-layout" });
2754
- }, [wb, registry, overrides?.getDefaultNodeSize]);
2947
+ }, [wb, wb.def, registry, overrides?.getDefaultNodeSize]);
2755
2948
  const updateEdgeType = React.useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2756
2949
  const triggerExternal = React.useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
2757
2950
  // Helper to save runtime metadata and UI state to extData
2758
- const saveUiRuntimeMetadata = React.useCallback(async () => {
2951
+ const saveUiRuntimeMetadata = React.useCallback(async (workbench, graphRunner) => {
2759
2952
  try {
2760
- const current = wb.getRuntimeState() ?? { nodes: {} };
2953
+ const current = workbench.getRuntimeState() ?? { nodes: {} };
2761
2954
  const metadata = { nodes: { ...current.nodes } };
2762
2955
  // Clean up metadata for nodes that no longer exist
2763
- const nodeIds = new Set(def.nodes.map((n) => n.nodeId));
2956
+ const nodeIds = new Set(workbench.def.nodes.map((n) => n.nodeId));
2764
2957
  for (const nodeId of Object.keys(metadata.nodes)) {
2765
2958
  if (!nodeIds.has(nodeId)) {
2766
2959
  delete metadata.nodes[nodeId];
2767
2960
  }
2768
2961
  }
2769
2962
  // Save cleaned metadata to workbench state
2770
- wb.setRuntimeState(metadata);
2771
- const fullUiState = wb.getUIState();
2963
+ workbench.setRuntimeState(metadata);
2964
+ const fullUiState = workbench.getUIState();
2772
2965
  const uiWithoutViewport = excludeViewportFromUIState(fullUiState);
2773
- await runner.setExtData?.({
2966
+ await graphRunner.setExtData?.({
2774
2967
  ...(Object.keys(uiWithoutViewport || {}).length > 0
2775
2968
  ? { ui: uiWithoutViewport }
2776
2969
  : {}),
@@ -2780,7 +2973,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2780
2973
  catch (err) {
2781
2974
  console.warn("[WorkbenchContext] Failed to save runtime metadata:", err);
2782
2975
  }
2783
- }, [wb, def, runner]);
2976
+ }, []);
2784
2977
  // Subscribe to runner/workbench events
2785
2978
  React.useEffect(() => {
2786
2979
  const add = (source, type) => (payload) => setEvents((prev) => {
@@ -2806,9 +2999,8 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2806
2999
  });
2807
3000
  // Helper to apply resolved handles from event payload to workbench
2808
3001
  const applyResolvedHandles = (resolvedHandles) => {
2809
- const cur = wb.export();
2810
3002
  let changed = false;
2811
- for (const n of cur.nodes) {
3003
+ for (const n of wb.def.nodes) {
2812
3004
  const updated = resolvedHandles[n.nodeId];
2813
3005
  if (updated) {
2814
3006
  const before = JSON.stringify(n.resolvedHandles || {});
@@ -3097,6 +3289,9 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3097
3289
  else if (changeType === "updateEdgeType") {
3098
3290
  reason = "update-edge-type";
3099
3291
  }
3292
+ else if (changeType === "setInputs") {
3293
+ reason = "set-inputs";
3294
+ }
3100
3295
  }
3101
3296
  if (event.change?.type === "setInputs") {
3102
3297
  const { nodeId, inputs } = event.change;
@@ -3104,7 +3299,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3104
3299
  }
3105
3300
  if (!runner.isRunning()) {
3106
3301
  if (event.commit) {
3107
- await saveUiRuntimeMetadata();
3302
+ await saveUiRuntimeMetadata(wb, runner);
3108
3303
  const history = await runner.commit(reason).catch((err) => {
3109
3304
  console.error("[WorkbenchContext] Error committing:", err);
3110
3305
  return undefined;
@@ -3137,7 +3332,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3137
3332
  await runner.update(event.def, { dry: event.dry });
3138
3333
  }
3139
3334
  if (event.commit) {
3140
- await saveUiRuntimeMetadata();
3335
+ await saveUiRuntimeMetadata(wb, runner);
3141
3336
  const history = await runner
3142
3337
  .commit(event.reason ?? reason)
3143
3338
  .catch((err) => {
@@ -3158,7 +3353,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3158
3353
  setSelectedNodeId(sel.nodes?.[0]);
3159
3354
  setSelectedEdgeId(sel.edges?.[0]);
3160
3355
  if (sel.commit) {
3161
- await saveUiRuntimeMetadata();
3356
+ await saveUiRuntimeMetadata(wb, runner);
3162
3357
  const history = await runner
3163
3358
  .commit(sel.reason ?? "selection")
3164
3359
  .catch((err) => {
@@ -3197,7 +3392,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3197
3392
  reason = "selection";
3198
3393
  }
3199
3394
  }
3200
- await saveUiRuntimeMetadata();
3395
+ await saveUiRuntimeMetadata(wb, runner);
3201
3396
  const history = await runner
3202
3397
  .commit(event.reason ?? reason)
3203
3398
  .catch((err) => {
@@ -3217,7 +3412,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3217
3412
  wb.setRegistry(newReg);
3218
3413
  // Trigger a graph update so the UI revalidates with new types/enums/nodes
3219
3414
  try {
3220
- await runner.update(wb.export());
3415
+ await runner.update(wb.def);
3221
3416
  }
3222
3417
  catch {
3223
3418
  console.error("Failed to update graph definition after registry changed");
@@ -3240,7 +3435,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3240
3435
  setNodeStatus(() => {
3241
3436
  const next = {};
3242
3437
  const metadata = wb.getRuntimeState() ?? { nodes: {} };
3243
- for (const n of def.nodes) {
3438
+ for (const n of wb.def.nodes) {
3244
3439
  const nodeMeta = metadata.nodes[n.nodeId];
3245
3440
  next[n.nodeId] = {
3246
3441
  activeRuns: 0,
@@ -3255,6 +3450,30 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3255
3450
  errorRunsRef.current = {};
3256
3451
  }
3257
3452
  });
3453
+ const offWbRuntimeMetadataChanged = wb.on("runtimeMetadataChanged", (event) => {
3454
+ const workbenchRuntimeState = wb.getRuntimeState() ?? { nodes: {} };
3455
+ setNodeStatus((prev) => {
3456
+ const next = { ...prev };
3457
+ const metadata = workbenchRuntimeState;
3458
+ let changed = false;
3459
+ // If nodeId is specified, only update that node; otherwise update all nodes
3460
+ const nodesToUpdate = event.nodeId
3461
+ ? wb.def.nodes.filter((n) => n.nodeId === event.nodeId)
3462
+ : wb.def.nodes;
3463
+ for (const n of nodesToUpdate) {
3464
+ const cur = next[n.nodeId];
3465
+ if (!cur)
3466
+ continue;
3467
+ const nodeMeta = metadata.nodes[n.nodeId];
3468
+ const newInvalidated = computeInvalidatedFromMetadata(nodeMeta);
3469
+ if (cur.invalidated !== newInvalidated) {
3470
+ next[n.nodeId] = { ...cur, invalidated: newInvalidated };
3471
+ changed = true;
3472
+ }
3473
+ }
3474
+ return changed ? next : prev;
3475
+ });
3476
+ });
3258
3477
  wb.refreshValidation();
3259
3478
  return () => {
3260
3479
  offRunnerValue();
@@ -3272,13 +3491,14 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3272
3491
  offRunnerRegistry();
3273
3492
  offRunnerTransport();
3274
3493
  offFlowViewport();
3494
+ offWbRuntimeMetadataChanged();
3275
3495
  };
3276
3496
  }, [runner, wb, setRegistry]);
3277
3497
  const isRunning = React.useCallback(() => runner.isRunning(), [runner]);
3278
3498
  const engineKind = React.useCallback(() => runner.getRunningEngine(), [runner]);
3279
3499
  const start = React.useCallback((engine) => {
3280
3500
  try {
3281
- runner.launch(wb.export(), { engine });
3501
+ runner.launch(wb.def, { engine });
3282
3502
  }
3283
3503
  catch { }
3284
3504
  }, [runner, wb]);
@@ -3353,7 +3573,6 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3353
3573
  runner,
3354
3574
  registry,
3355
3575
  setRegistry,
3356
- def,
3357
3576
  selectedNodeId,
3358
3577
  selectedEdgeId,
3359
3578
  setSelection,
@@ -3394,7 +3613,6 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3394
3613
  runner,
3395
3614
  registry,
3396
3615
  setRegistry,
3397
- def,
3398
3616
  selectedNodeId,
3399
3617
  selectedEdgeId,
3400
3618
  setSelection,
@@ -3434,137 +3652,23 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
3434
3652
  }
3435
3653
 
3436
3654
  function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap, outputTypesMap, onClose, getDefaultNodeSize, onCopyResult) {
3437
- const doBake = async (handleId) => {
3438
- try {
3439
- const typeId = outputTypesMap?.[nodeId]?.[handleId];
3440
- const raw = outputsMap?.[nodeId]?.[handleId];
3441
- let newNodeId;
3442
- if (!typeId || raw === undefined)
3443
- return;
3444
- const unwrap = (v) => sparkGraph.isTypedOutput(v) ? sparkGraph.getTypedOutputValue(v) : v;
3445
- const coerceIfNeeded = async (fromType, toType, value) => {
3446
- if (!toType || toType === fromType || !runner?.coerce)
3447
- return value;
3448
- try {
3449
- return await runner.coerce(fromType, toType, value);
3450
- }
3451
- catch {
3452
- return value;
3453
- }
3454
- };
3455
- const pos = wb.getPositions()[nodeId] || { x: 0, y: 0 };
3456
- const isArray = typeId.endsWith("[]");
3457
- const baseTypeId = isArray ? typeId.slice(0, -2) : typeId;
3458
- const tArr = isArray ? registry.types.get(typeId) : undefined;
3459
- const tElem = registry.types.get(baseTypeId);
3460
- const singleTarget = !isArray ? tElem?.bakeTarget : undefined;
3461
- const arrTarget = isArray ? tArr?.bakeTarget : undefined;
3462
- const elemTarget = isArray ? tElem?.bakeTarget : undefined;
3463
- if (singleTarget) {
3464
- const nodeDesc = registry.nodes.get(singleTarget.nodeTypeId);
3465
- const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, singleTarget.inputHandle);
3466
- const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3467
- newNodeId = wb.addNode({
3468
- typeId: singleTarget.nodeTypeId,
3469
- position: { x: pos.x + 180, y: pos.y },
3470
- }, { inputs: { [singleTarget.inputHandle]: coerced } });
3471
- }
3472
- else if (isArray && arrTarget) {
3473
- const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3474
- const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3475
- const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3476
- newNodeId = wb.addNode({
3477
- typeId: arrTarget.nodeTypeId,
3478
- position: { x: pos.x + 180, y: pos.y },
3479
- }, { inputs: { [arrTarget.inputHandle]: coerced } });
3480
- }
3481
- else if (isArray && elemTarget) {
3482
- const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
3483
- const inType = sparkGraph.getInputTypeId(nodeDesc?.inputs, elemTarget.inputHandle);
3484
- const src = unwrap(raw);
3485
- const items = Array.isArray(src) ? src : [src];
3486
- const coercedItems = await Promise.all(items.map((v) => coerceIfNeeded(baseTypeId, inType, v)));
3487
- const COLS = 4;
3488
- const DX = 180;
3489
- const DY = 160;
3490
- for (let idx = 0; idx < coercedItems.length; idx++) {
3491
- const col = idx % COLS;
3492
- const row = Math.floor(idx / COLS);
3493
- newNodeId = wb.addNode({
3494
- typeId: elemTarget.nodeTypeId,
3495
- position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3496
- }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
3497
- }
3498
- }
3499
- if (newNodeId) {
3500
- wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "bake" });
3501
- }
3502
- }
3503
- catch { }
3504
- };
3505
3655
  return {
3506
3656
  onDelete: () => {
3507
3657
  wb.removeNode(nodeId, { commit: true });
3508
3658
  onClose();
3509
3659
  },
3510
3660
  onDuplicate: async () => {
3511
- const def = wb.export();
3512
- const n = def.nodes.find((n) => n.nodeId === nodeId);
3513
- if (!n)
3514
- return onClose();
3515
- const pos = wb.getPositions()[nodeId] || { x: 0, y: 0 };
3516
- const inboundHandles = new Set(def.edges
3517
- .filter((e) => e.target.nodeId === nodeId)
3518
- .map((e) => e.target.handle));
3519
- const allInputs = runner.getInputs(def)[nodeId] || {};
3520
- const inputsWithoutBindings = Object.fromEntries(Object.entries(allInputs).filter(([handle]) => !inboundHandles.has(handle)));
3521
- const newNodeId = wb.addNode({
3522
- typeId: n.typeId,
3523
- params: n.params,
3524
- position: { x: pos.x + 24, y: pos.y + 24 },
3525
- resolvedHandles: n.resolvedHandles,
3526
- }, {
3527
- inputs: inputsWithoutBindings,
3528
- copyOutputsFrom: nodeId,
3529
- dry: true,
3661
+ wb.duplicateNode(nodeId, runner, {
3662
+ commit: true,
3663
+ reason: "duplicate-node",
3530
3664
  });
3531
- // Select the newly duplicated node
3532
- wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate" });
3533
3665
  onClose();
3534
3666
  },
3535
3667
  onDuplicateWithEdges: async () => {
3536
- const def = wb.export();
3537
- const n = def.nodes.find((n) => n.nodeId === nodeId);
3538
- if (!n)
3539
- return onClose();
3540
- const pos = wb.getPositions()[nodeId] || { x: 0, y: 0 };
3541
- // Get inputs without bindings (literal values only)
3542
- const inputs = runner.getInputs(def)[nodeId] || {};
3543
- // Add the duplicated node
3544
- const newNodeId = wb.addNode({
3545
- typeId: n.typeId,
3546
- params: n.params,
3547
- position: { x: pos.x + 24, y: pos.y + 24 },
3548
- resolvedHandles: n.resolvedHandles,
3549
- }, {
3550
- inputs,
3551
- copyOutputsFrom: nodeId,
3552
- dry: true,
3668
+ wb.duplicateNodeWithEdges(nodeId, runner, {
3669
+ commit: true,
3670
+ reason: "duplicate-node-with-edges",
3553
3671
  });
3554
- // Find all incoming edges (edges where target is the original node)
3555
- const incomingEdges = def.edges.filter((e) => e.target.nodeId === nodeId);
3556
- // Duplicate each incoming edge to point to the new node
3557
- for (const edge of incomingEdges) {
3558
- wb.connect({
3559
- source: edge.source, // Keep the same source
3560
- target: { nodeId: newNodeId, handle: edge.target.handle }, // Point to new node
3561
- typeId: edge.typeId,
3562
- }, { dry: true });
3563
- }
3564
- // Select the newly duplicated node and edges
3565
- if (newNodeId) {
3566
- wb.setSelection({ nodes: [newNodeId], edges: [] }, { commit: true, reason: "duplicate-with-edges" });
3567
- }
3568
3672
  onClose();
3569
3673
  },
3570
3674
  onRunPull: async () => {
@@ -3575,7 +3679,13 @@ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap,
3575
3679
  onClose();
3576
3680
  },
3577
3681
  onBake: async (handleId) => {
3578
- await doBake(handleId);
3682
+ const nodePosition = wb.getPositions()[nodeId] || { x: 0, y: 0 };
3683
+ const typeId = outputTypesMap?.[nodeId]?.[handleId];
3684
+ const raw = outputsMap?.[nodeId]?.[handleId];
3685
+ await wb.bake(registry, runner, raw, typeId || "", nodePosition, {
3686
+ commit: true,
3687
+ reason: "bake",
3688
+ });
3579
3689
  onClose();
3580
3690
  },
3581
3691
  onCopy: () => {
@@ -3632,42 +3742,10 @@ function createNodeCopyHandler(wb, runner, nodeId, getDefaultNodeSize, onCopyRes
3632
3742
  function createSelectionContextMenuHandlers(wb, onClose, getDefaultNodeSize, onCopyResult, runner) {
3633
3743
  const onDuplicate = runner
3634
3744
  ? () => {
3635
- const selection = wb.getSelection();
3636
- if (selection.nodes.length === 0) {
3637
- onClose();
3638
- return;
3639
- }
3640
- const def = wb.export();
3641
- const positions = wb.getPositions();
3642
- const newNodes = [];
3643
- // Duplicate each selected node
3644
- for (const nodeId of selection.nodes) {
3645
- const n = def.nodes.find((n) => n.nodeId === nodeId);
3646
- if (!n)
3647
- continue;
3648
- const pos = positions[nodeId] || { x: 0, y: 0 };
3649
- // Get inputs without bindings (literal values only)
3650
- const allInputs = runner.getInputs(def)[nodeId] || {};
3651
- const inboundHandles = new Set(def.edges
3652
- .filter((e) => e.target.nodeId === nodeId)
3653
- .map((e) => e.target.handle));
3654
- const inputsWithoutBindings = Object.fromEntries(Object.entries(allInputs).filter(([handle]) => !inboundHandles.has(handle)));
3655
- const newNodeId = wb.addNode({
3656
- typeId: n.typeId,
3657
- params: n.params,
3658
- position: { x: pos.x + 24, y: pos.y + 24 },
3659
- resolvedHandles: n.resolvedHandles,
3660
- }, {
3661
- inputs: inputsWithoutBindings,
3662
- copyOutputsFrom: nodeId,
3663
- dry: true,
3664
- });
3665
- newNodes.push(newNodeId);
3666
- }
3667
- // Select all newly duplicated nodes
3668
- if (newNodes.length > 0) {
3669
- wb.setSelection({ nodes: newNodes, edges: [] }, { commit: true, reason: "duplicate-selection" });
3670
- }
3745
+ wb.duplicateSelection(runner, {
3746
+ commit: true,
3747
+ reason: "duplicate-selection",
3748
+ });
3671
3749
  onClose();
3672
3750
  }
3673
3751
  : undefined;
@@ -3702,9 +3780,8 @@ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, g
3702
3780
  const canRedo = history ? history.redoCount > 0 : undefined;
3703
3781
  const onSelectAll = wb
3704
3782
  ? () => {
3705
- const def = wb.export();
3706
- const allNodeIds = def.nodes.map((n) => n.nodeId);
3707
- const allEdgeIds = def.edges.map((e) => e.id);
3783
+ const allNodeIds = wb.def.nodes.map((n) => n.nodeId);
3784
+ const allEdgeIds = wb.def.edges.map((e) => e.id);
3708
3785
  wb.setSelection({ nodes: allNodeIds, edges: allEdgeIds }, { commit: true, reason: "select-all" });
3709
3786
  onClose();
3710
3787
  }
@@ -3723,8 +3800,7 @@ function createDefaultContextMenuHandlers(onAddNode, onClose, onPaste, runner, g
3723
3800
  }
3724
3801
  function getBakeableOutputs(nodeId, wb, registry, outputTypesMap) {
3725
3802
  try {
3726
- const def = wb.export();
3727
- const node = def.nodes.find((n) => n.nodeId === nodeId);
3803
+ const node = wb.def.nodes.find((n) => n.nodeId === nodeId);
3728
3804
  if (!node)
3729
3805
  return [];
3730
3806
  const desc = registry.nodes.get(node.typeId);
@@ -3823,13 +3899,13 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
3823
3899
  return String(value ?? "");
3824
3900
  }
3825
3901
  };
3826
- const { registry, def, selectedNodeId, selectedEdgeId, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, nodeStatus, edgeStatus, validationByNode, validationByEdge, validationGlobal, valuesTick, updateEdgeType, systemErrors, registryErrors, inputValidationErrors, clearSystemErrors, clearRegistryErrors, clearInputValidationErrors, removeSystemError, removeRegistryError, removeInputValidationError, } = useWorkbenchContext();
3902
+ const { wb, registry, selectedNodeId, selectedEdgeId, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, nodeStatus, edgeStatus, validationByNode, validationByEdge, validationGlobal, valuesTick, updateEdgeType, systemErrors, registryErrors, inputValidationErrors, clearSystemErrors, clearRegistryErrors, clearInputValidationErrors, removeSystemError, removeRegistryError, removeInputValidationError, } = useWorkbenchContext();
3827
3903
  const nodeValidationIssues = validationByNode.issues;
3828
3904
  const edgeValidationIssues = validationByEdge.issues;
3829
3905
  const nodeValidationHandles = validationByNode;
3830
3906
  const globalValidationIssues = validationGlobal;
3831
- const selectedNode = def.nodes.find((n) => n.nodeId === selectedNodeId);
3832
- const selectedEdge = def.edges.find((e) => e.id === selectedEdgeId);
3907
+ const selectedNode = wb.def.nodes.find((n) => n.nodeId === selectedNodeId);
3908
+ const selectedEdge = wb.def.edges.find((e) => e.id === selectedEdgeId);
3833
3909
  // Use computeEffectiveHandles to merge registry defaults with dynamically resolved handles
3834
3910
  const effectiveHandles = selectedNode
3835
3911
  ? computeEffectiveHandles(selectedNode, registry)
@@ -3961,7 +4037,6 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
3961
4037
  setOriginals(nextOriginals);
3962
4038
  }, [selectedNodeId, selectedNode, registry, valuesTick]);
3963
4039
  const widthClass = debug ? "w-[480px]" : "w-[320px]";
3964
- const { wb } = useWorkbenchContext();
3965
4040
  const deleteEdgeById = (edgeId) => {
3966
4041
  if (!edgeId)
3967
4042
  return;
@@ -3988,9 +4063,9 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
3988
4063
  selectedNodeStatus.activeRunIds.length > 0 ? (jsxRuntime.jsxs("div", { className: "mt-1", children: [jsxRuntime.jsx("div", { className: "text-[10px] text-blue-600", children: "RunIds:" }), jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1 mt-1", children: selectedNodeStatus.activeRunIds.map((runId, idx) => (jsxRuntime.jsx("span", { className: "text-[10px] px-1.5 py-0.5 bg-blue-100 border border-blue-300 rounded font-mono", children: runId }, idx))) })] })) : (jsxRuntime.jsx("div", { className: "text-[10px] text-blue-600 mt-1", children: "RunIds not available (some runs may have started without runId)" }))] })), !!selectedNodeStatus?.lastError && (jsxRuntime.jsx("div", { className: "mt-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 break-words", children: String(selectedNodeStatus.lastError?.message ??
3989
4064
  selectedNodeStatus.lastError) }))] })), jsxRuntime.jsxs("div", { className: "mb-2", children: [jsxRuntime.jsx("div", { className: "font-semibold mb-1", children: "Inputs" }), inputHandles.length === 0 ? (jsxRuntime.jsx("div", { className: "text-gray-500", children: "No inputs" })) : (inputHandles.map((h) => {
3990
4065
  const typeId = sparkGraph.getInputTypeId(effectiveHandles.inputs, h);
3991
- const isLinked = def.edges.some((e) => e.target.nodeId === selectedNodeId &&
4066
+ const isLinked = wb.def.edges.some((e) => e.target.nodeId === selectedNodeId &&
3992
4067
  e.target.handle === h);
3993
- const inbound = new Set(def.edges
4068
+ const inbound = new Set(wb.def.edges
3994
4069
  .filter((e) => e.target.nodeId === selectedNodeId &&
3995
4070
  e.target.handle === h)
3996
4071
  .map((e) => e.target.handle));
@@ -4566,10 +4641,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4566
4641
  const { nodeTypes, resolveNodeType } = React.useMemo(() => {
4567
4642
  // Build nodeTypes map using UI extension registry
4568
4643
  const custom = new Map(); // Include all types present in registry AND current graph to avoid timing issues
4569
- const def = wb.export();
4570
4644
  const ids = new Set([
4571
4645
  ...Array.from(registry.nodes.keys()),
4572
- ...def.nodes.map((n) => n.typeId),
4646
+ ...wb.def.nodes.map((n) => n.typeId),
4573
4647
  ]);
4574
4648
  for (const typeId of ids) {
4575
4649
  const renderer = ui.getNodeRenderer(typeId);
@@ -4588,14 +4662,13 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4588
4662
  // Include uiVersion to recompute when custom renderers are registered
4589
4663
  }, [wb, registry, uiVersion, ui]);
4590
4664
  const { nodes, edges } = React.useMemo(() => {
4591
- const def = wb.export();
4592
4665
  const sel = wb.getSelection();
4593
4666
  // Merge defaults with inputs for node display (defaults shown in lighter gray)
4594
4667
  const inputsWithDefaults = {};
4595
- for (const n of def.nodes) {
4668
+ for (const n of wb.def.nodes) {
4596
4669
  const nodeInputs = inputsMap[n.nodeId] ?? {};
4597
4670
  const nodeDefaults = inputDefaultsMap[n.nodeId] ?? {};
4598
- const inbound = new Set(def.edges
4671
+ const inbound = new Set(wb.def.edges
4599
4672
  .filter((e) => e.target.nodeId === n.nodeId)
4600
4673
  .map((e) => e.target.handle));
4601
4674
  const merged = { ...nodeInputs };
@@ -4608,7 +4681,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
4608
4681
  inputsWithDefaults[n.nodeId] = merged;
4609
4682
  }
4610
4683
  }
4611
- const out = toReactFlow(def, wb.getPositions(), registry, {
4684
+ const out = toReactFlow(wb.def, wb.getPositions(), registry, {
4612
4685
  showValues,
4613
4686
  inputs: inputsWithDefaults,
4614
4687
  inputDefaults: inputDefaultsMap,
@@ -5132,11 +5205,11 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5132
5205
  });
5133
5206
 
5134
5207
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {
5135
- const { wb, runner, registry, def, selectedNodeId, runAutoLayout } = useWorkbenchContext();
5208
+ const { wb, runner, registry, selectedNodeId, runAutoLayout } = useWorkbenchContext();
5136
5209
  const [transportStatus, setTransportStatus] = React.useState({
5137
5210
  state: "local",
5138
5211
  });
5139
- const selectedNode = def.nodes.find((n) => n.nodeId === selectedNodeId);
5212
+ const selectedNode = wb.def.nodes.find((n) => n.nodeId === selectedNodeId);
5140
5213
  const effectiveHandles = selectedNode
5141
5214
  ? computeEffectiveHandles(selectedNode, registry)
5142
5215
  : { inputs: {}, outputs: {}, inputDefaults: {} };
@@ -5164,7 +5237,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5164
5237
  if (evt.shiftKey && !confirm("Invalidate and re-run graph?"))
5165
5238
  return;
5166
5239
  try {
5167
- runner.launch(wb.export(), {
5240
+ runner.launch(wb.def, {
5168
5241
  engine: kind,
5169
5242
  invalidate: evt.shiftKey,
5170
5243
  });
@@ -5232,7 +5305,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5232
5305
  const setInitialGraph = async (d, inputs) => {
5233
5306
  await wb.load(d);
5234
5307
  try {
5235
- runner.build(wb.export());
5308
+ runner.build(wb.def);
5236
5309
  }
5237
5310
  catch { }
5238
5311
  if (inputs) {
@@ -5247,36 +5320,27 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5247
5320
  React.useEffect(() => {
5248
5321
  if (!onChange)
5249
5322
  return;
5250
- const off1 = wb.on("graphChanged", () => {
5251
- try {
5252
- const cur = wb.export();
5253
- const inputs = runner.getInputs(cur);
5254
- onChange({ def: cur, inputs });
5255
- }
5256
- catch { }
5257
- });
5258
- const off2 = runner.on("value", () => {
5323
+ const offGraphChanged = wb.on("graphChanged", () => {
5259
5324
  try {
5260
- const cur = wb.export();
5325
+ const cur = wb.def;
5261
5326
  const inputs = runner.getInputs(cur);
5262
5327
  onChange({ def: cur, inputs });
5263
5328
  }
5264
5329
  catch { }
5265
5330
  });
5266
- const off3 = wb.on("graphUiChanged", (evt) => {
5331
+ const offGraphUiChanged = wb.on("graphUiChanged", (evt) => {
5267
5332
  if (!evt.commit)
5268
5333
  return;
5269
5334
  try {
5270
- const cur = wb.export();
5335
+ const cur = wb.def;
5271
5336
  const inputs = runner.getInputs(cur);
5272
5337
  onChange({ def: cur, inputs });
5273
5338
  }
5274
5339
  catch { }
5275
5340
  });
5276
5341
  return () => {
5277
- off1();
5278
- off2();
5279
- off3();
5342
+ offGraphChanged();
5343
+ offGraphUiChanged();
5280
5344
  };
5281
5345
  }, [wb, runner, onChange]);
5282
5346
  const applyExample = React.useCallback(async (key) => {
@@ -5299,7 +5363,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5299
5363
  }
5300
5364
  await wb.load(def);
5301
5365
  // Build a local runtime so seeded defaults are visible pre-run
5302
- runner.build(wb.export());
5366
+ runner.build(wb.def);
5303
5367
  // Set initial inputs if provided
5304
5368
  if (inputs) {
5305
5369
  for (const [nodeId, map] of Object.entries(inputs)) {
@@ -5384,11 +5448,10 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5384
5448
  // Only auto-launch for local backend; require explicit Start for remote
5385
5449
  if (backendKind !== "local")
5386
5450
  return;
5387
- const d = wb.export();
5388
- if (!d.nodes || d.nodes.length === 0)
5451
+ if (!wb.def.nodes || wb.def.nodes.length === 0)
5389
5452
  return;
5390
5453
  try {
5391
- runner.launch(d, { engine: engine });
5454
+ runner.launch(wb.def, { engine: engine });
5392
5455
  }
5393
5456
  catch {
5394
5457
  // ignore
@@ -5398,7 +5461,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5398
5461
  if (!selectedNodeId)
5399
5462
  return;
5400
5463
  // If selected input is wired (has inbound edge), ignore user input to respect runtime value
5401
- const isLinked = def.edges.some((e) => e.target.nodeId === selectedNodeId && e.target.handle === handle);
5464
+ const isLinked = wb.def.edges.some((e) => e.target.nodeId === selectedNodeId && e.target.handle === handle);
5402
5465
  if (isLinked)
5403
5466
  return;
5404
5467
  // If raw is undefined, pass it through to delete the input value
@@ -5483,7 +5546,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5483
5546
  }
5484
5547
  }
5485
5548
  wb.setInputs(selectedNodeId, { [handle]: value }, { commit: true });
5486
- }, [selectedNodeId, def.edges, effectiveHandles, wb]);
5549
+ }, [selectedNodeId, wb.def.edges, effectiveHandles, wb]);
5487
5550
  const setInput = React.useMemo(() => {
5488
5551
  if (overrides?.setInput) {
5489
5552
  return overrides.setInput(baseSetInput, {