@bian-womp/spark-graph 0.3.1 → 0.3.3

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 (79) hide show
  1. package/lib/cjs/index.cjs +1417 -1220
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/types.d.ts +1 -1
  4. package/lib/cjs/src/core/types.d.ts.map +1 -1
  5. package/lib/cjs/src/index.d.ts +3 -3
  6. package/lib/cjs/src/index.d.ts.map +1 -1
  7. package/lib/cjs/src/misc/utils/json.d.ts +9 -0
  8. package/lib/cjs/src/misc/utils/json.d.ts.map +1 -1
  9. package/lib/cjs/src/runtime/GraphLifecycleApi.d.ts +4 -1
  10. package/lib/cjs/src/runtime/GraphLifecycleApi.d.ts.map +1 -1
  11. package/lib/cjs/src/runtime/GraphRuntime.d.ts +18 -27
  12. package/lib/cjs/src/runtime/GraphRuntime.d.ts.map +1 -1
  13. package/lib/cjs/src/runtime/{UnifiedEngine.d.ts → LocalEngine.d.ts} +20 -7
  14. package/lib/cjs/src/runtime/LocalEngine.d.ts.map +1 -0
  15. package/lib/cjs/src/runtime/components/EdgePropagator.d.ts +101 -0
  16. package/lib/cjs/src/runtime/components/EdgePropagator.d.ts.map +1 -0
  17. package/lib/cjs/src/runtime/components/Graph.d.ts +31 -0
  18. package/lib/cjs/src/runtime/components/Graph.d.ts.map +1 -0
  19. package/lib/cjs/src/runtime/components/HandleResolver.d.ts +11 -8
  20. package/lib/cjs/src/runtime/components/HandleResolver.d.ts.map +1 -1
  21. package/lib/cjs/src/runtime/components/NodeExecutor.d.ts +108 -0
  22. package/lib/cjs/src/runtime/components/NodeExecutor.d.ts.map +1 -0
  23. package/lib/cjs/src/runtime/components/RunContextManager.d.ts +26 -13
  24. package/lib/cjs/src/runtime/components/RunContextManager.d.ts.map +1 -1
  25. package/lib/cjs/src/runtime/components/graph-utils.d.ts +22 -0
  26. package/lib/cjs/src/runtime/components/graph-utils.d.ts.map +1 -0
  27. package/lib/cjs/src/runtime/components/interfaces.d.ts +9 -13
  28. package/lib/cjs/src/runtime/components/interfaces.d.ts.map +1 -1
  29. package/lib/cjs/src/runtime/components/types.d.ts +1 -10
  30. package/lib/cjs/src/runtime/components/types.d.ts.map +1 -1
  31. package/lib/esm/index.js +1416 -1220
  32. package/lib/esm/index.js.map +1 -1
  33. package/lib/esm/src/core/types.d.ts +1 -1
  34. package/lib/esm/src/core/types.d.ts.map +1 -1
  35. package/lib/esm/src/index.d.ts +3 -3
  36. package/lib/esm/src/index.d.ts.map +1 -1
  37. package/lib/esm/src/misc/utils/json.d.ts +9 -0
  38. package/lib/esm/src/misc/utils/json.d.ts.map +1 -1
  39. package/lib/esm/src/runtime/GraphLifecycleApi.d.ts +4 -1
  40. package/lib/esm/src/runtime/GraphLifecycleApi.d.ts.map +1 -1
  41. package/lib/esm/src/runtime/GraphRuntime.d.ts +18 -27
  42. package/lib/esm/src/runtime/GraphRuntime.d.ts.map +1 -1
  43. package/lib/esm/src/runtime/{UnifiedEngine.d.ts → LocalEngine.d.ts} +20 -7
  44. package/lib/esm/src/runtime/LocalEngine.d.ts.map +1 -0
  45. package/lib/esm/src/runtime/components/EdgePropagator.d.ts +101 -0
  46. package/lib/esm/src/runtime/components/EdgePropagator.d.ts.map +1 -0
  47. package/lib/esm/src/runtime/components/Graph.d.ts +31 -0
  48. package/lib/esm/src/runtime/components/Graph.d.ts.map +1 -0
  49. package/lib/esm/src/runtime/components/HandleResolver.d.ts +11 -8
  50. package/lib/esm/src/runtime/components/HandleResolver.d.ts.map +1 -1
  51. package/lib/esm/src/runtime/components/NodeExecutor.d.ts +108 -0
  52. package/lib/esm/src/runtime/components/NodeExecutor.d.ts.map +1 -0
  53. package/lib/esm/src/runtime/components/RunContextManager.d.ts +26 -13
  54. package/lib/esm/src/runtime/components/RunContextManager.d.ts.map +1 -1
  55. package/lib/esm/src/runtime/components/graph-utils.d.ts +22 -0
  56. package/lib/esm/src/runtime/components/graph-utils.d.ts.map +1 -0
  57. package/lib/esm/src/runtime/components/interfaces.d.ts +9 -13
  58. package/lib/esm/src/runtime/components/interfaces.d.ts.map +1 -1
  59. package/lib/esm/src/runtime/components/types.d.ts +1 -10
  60. package/lib/esm/src/runtime/components/types.d.ts.map +1 -1
  61. package/package.json +2 -2
  62. package/lib/cjs/src/runtime/AbstractEngine.d.ts +0 -28
  63. package/lib/cjs/src/runtime/AbstractEngine.d.ts.map +0 -1
  64. package/lib/cjs/src/runtime/UnifiedEngine.d.ts.map +0 -1
  65. package/lib/cjs/src/runtime/components/ExecutionScheduler.d.ts +0 -56
  66. package/lib/cjs/src/runtime/components/ExecutionScheduler.d.ts.map +0 -1
  67. package/lib/cjs/src/runtime/components/GraphStructure.d.ts +0 -36
  68. package/lib/cjs/src/runtime/components/GraphStructure.d.ts.map +0 -1
  69. package/lib/cjs/src/runtime/components/ValuePropagator.d.ts +0 -46
  70. package/lib/cjs/src/runtime/components/ValuePropagator.d.ts.map +0 -1
  71. package/lib/esm/src/runtime/AbstractEngine.d.ts +0 -28
  72. package/lib/esm/src/runtime/AbstractEngine.d.ts.map +0 -1
  73. package/lib/esm/src/runtime/UnifiedEngine.d.ts.map +0 -1
  74. package/lib/esm/src/runtime/components/ExecutionScheduler.d.ts +0 -56
  75. package/lib/esm/src/runtime/components/ExecutionScheduler.d.ts.map +0 -1
  76. package/lib/esm/src/runtime/components/GraphStructure.d.ts +0 -36
  77. package/lib/esm/src/runtime/components/GraphStructure.d.ts.map +0 -1
  78. package/lib/esm/src/runtime/components/ValuePropagator.d.ts +0 -46
  79. package/lib/esm/src/runtime/components/ValuePropagator.d.ts.map +0 -1
package/lib/cjs/index.cjs CHANGED
@@ -405,48 +405,14 @@ class Registry {
405
405
  Registry.idCounter = 0;
406
406
 
407
407
  /**
408
- * Shared utility functions for runtime components
409
- */
410
- /**
411
- * Type guard to check if a value is a Promise
412
- */
413
- function isPromise(value) {
414
- return !!value && typeof value.then === "function";
415
- }
416
- /**
417
- * Unwrap a value that might be a Promise
418
- */
419
- async function unwrapMaybePromise(value) {
420
- return isPromise(value) ? await value : value;
421
- }
422
- /**
423
- * Shallow/deep-ish equality check to avoid unnecessary runs on identical values
424
- */
425
- function valuesEqual(a, b) {
426
- if (a === b)
427
- return true;
428
- if (typeof a !== typeof b)
429
- return false;
430
- if (a && b && typeof a === "object") {
431
- try {
432
- return JSON.stringify(a) === JSON.stringify(b);
433
- }
434
- catch {
435
- return false;
436
- }
437
- }
438
- return false;
439
- }
440
-
441
- /**
442
- * GraphStructure component - manages nodes, edges, and handle resolution
408
+ * Graph component - manages nodes, edges, and handle resolution
443
409
  */
444
- class GraphStructure {
410
+ class Graph {
445
411
  constructor(registry) {
412
+ this.registry = registry;
446
413
  this.nodes = new Map();
447
414
  this.edges = [];
448
415
  this.resolvedByNode = new Map();
449
- this.registry = registry;
450
416
  }
451
417
  // Node accessors
452
418
  getNode(nodeId) {
@@ -488,99 +454,21 @@ class GraphStructure {
488
454
  getResolvedHandlesMap() {
489
455
  return this.resolvedByNode;
490
456
  }
491
- // Static factory methods
492
- static computeResolvedHandleMap(def, registry, environment) {
493
- const out = new Map();
494
- const pending = new Set();
495
- for (const n of def.nodes) {
496
- const desc = registry.nodes.get(n.typeId);
497
- if (!desc)
498
- continue;
499
- const overrideInputs = n.resolvedHandles?.inputs;
500
- const overrideOutputs = n.resolvedHandles?.outputs;
501
- const overrideDefaults = n.resolvedHandles?.inputDefaults;
502
- // Resolve dynamic handles if available (initial pass: inputs may be undefined)
503
- let dyn = {};
504
- try {
505
- if (typeof desc.resolveHandles === "function") {
506
- const maybe = desc.resolveHandles({
507
- nodeId: n.nodeId,
508
- environment: environment || {},
509
- params: n.params,
510
- inputs: undefined,
511
- });
512
- // Only use sync results here; async results are applied via recompute later
513
- if (isPromise(maybe)) {
514
- // mark node as pending async recompute
515
- pending.add(n.nodeId);
516
- }
517
- else {
518
- dyn = maybe || {};
519
- }
520
- }
521
- }
522
- catch {
523
- // ignore dynamic resolution errors at this stage
524
- }
525
- // Merge base with dynamic and overrides (allow partial resolvedHandles)
526
- const inputs = {
527
- ...desc.inputs,
528
- ...dyn.inputs,
529
- ...overrideInputs,
530
- };
531
- const outputs = {
532
- ...desc.outputs,
533
- ...dyn.outputs,
534
- ...overrideOutputs,
535
- };
536
- const inputDefaults = {
537
- ...desc.inputDefaults,
538
- ...dyn.inputDefaults,
539
- ...overrideDefaults,
540
- };
541
- out.set(n.nodeId, { inputs, outputs, inputDefaults });
457
+ /**
458
+ * Check if all inbound edges for a node have values
459
+ */
460
+ allInboundHaveValue(nodeId) {
461
+ const node = this.nodes.get(nodeId);
462
+ if (!node)
463
+ return false;
464
+ const inbound = this.edges.filter((e) => e.target.nodeId === nodeId);
465
+ if (inbound.length === 0)
466
+ return true;
467
+ for (const e of inbound) {
468
+ if (!(e.target.handle in node.inputs))
469
+ return false;
542
470
  }
543
- return { map: out, pending };
544
- }
545
- static buildEdges(def, registry, resolvedByNode) {
546
- return def.edges.map((e) => {
547
- const srcNode = def.nodes.find((n) => n.nodeId === e.source.nodeId);
548
- const dstNode = def.nodes.find((n) => n.nodeId === e.target.nodeId);
549
- let effectiveTypeId = e.typeId; // Start with original
550
- let srcDeclared;
551
- let dstDeclared;
552
- if (srcNode) {
553
- const resolved = resolvedByNode.get(srcNode.nodeId);
554
- if (resolved)
555
- srcDeclared = resolved.outputs[e.source.handle];
556
- }
557
- if (!effectiveTypeId) {
558
- // Infer if not explicitly set
559
- effectiveTypeId = Array.isArray(srcDeclared)
560
- ? srcDeclared[0]
561
- : srcDeclared;
562
- }
563
- if (dstNode) {
564
- const resolved = resolvedByNode.get(dstNode.nodeId);
565
- if (resolved)
566
- dstDeclared = getInputTypeId(resolved.inputs, e.target.handle);
567
- }
568
- const { convert, convertAsync } = GraphStructure.buildEdgeConverters(srcDeclared, dstDeclared, registry, `buildEdges: ${srcNode?.typeId || ""}.${e.source.nodeId}.${e.source.handle} -> ${dstNode?.typeId || ""}.${e.target.nodeId}.${e.target.handle}`);
569
- return {
570
- id: e.id,
571
- source: { ...e.source },
572
- target: { ...e.target },
573
- typeId: e.typeId, // Preserve original (may be undefined)
574
- effectiveTypeId: effectiveTypeId ?? "untyped", // Always present
575
- convert,
576
- convertAsync,
577
- srcUnionTypes: Array.isArray(srcDeclared)
578
- ? [...srcDeclared]
579
- : undefined,
580
- dstDeclared,
581
- stats: { runs: 0, inFlight: false, progress: 0 },
582
- };
583
- });
471
+ return true;
584
472
  }
585
473
  // Clear all data
586
474
  clear() {
@@ -588,70 +476,6 @@ class GraphStructure {
588
476
  this.edges = [];
589
477
  this.resolvedByNode.clear();
590
478
  }
591
- // Static helper: build edge converters for type coercion
592
- static buildEdgeConverters(srcDeclared, dstDeclared, registry, edgeLabel) {
593
- if (!dstDeclared || !srcDeclared) {
594
- return {};
595
- }
596
- const isUnion = Array.isArray(srcDeclared);
597
- const srcTypes = isUnion ? srcDeclared : [srcDeclared];
598
- // Helper to get the coercion for a specific type
599
- const getCoercion = (typeId) => {
600
- return registry.resolveCoercion(typeId, dstDeclared);
601
- };
602
- // Resolve coercions for all source types
603
- const coercions = srcTypes.map(getCoercion);
604
- const hasAsync = coercions.some((r) => r?.kind === "async");
605
- // Helper to extract and validate typed output for unions
606
- const extractPayload = (v) => {
607
- const typeId = getTypedOutputTypeId(v);
608
- const payload = getTypedOutputValue(v);
609
- if (isUnion) {
610
- if (!typeId) {
611
- throw new Error(`Typed output required for union source (${edgeLabel}); allowed: ${srcTypes.join("|")}`);
612
- }
613
- if (!srcTypes.includes(typeId)) {
614
- throw new Error(`Invalid typed output ${typeId} (${edgeLabel}); allowed: ${srcTypes.join("|")}`);
615
- }
616
- }
617
- else if (typeId) {
618
- // Warn if typed output is used for non-union source
619
- console.warn(`Typed output ${typeId} is fed even though source is not union (${edgeLabel}): ${srcDeclared} -> ${dstDeclared}`);
620
- }
621
- return { typeId: typeId || srcTypes[0], payload };
622
- };
623
- if (hasAsync) {
624
- return {
625
- convertAsync: async (v, signal) => {
626
- const { typeId, payload } = extractPayload(v);
627
- const res = getCoercion(typeId);
628
- if (!res)
629
- return payload;
630
- if (res.kind === "async") {
631
- return await res.convertAsync(payload, signal);
632
- }
633
- return res.convert(payload);
634
- },
635
- };
636
- }
637
- // Sync path
638
- const firstCoercion = coercions.find((r) => r?.kind === "sync");
639
- if (!firstCoercion) {
640
- return {};
641
- }
642
- return {
643
- convert: (v) => {
644
- const { typeId, payload } = extractPayload(v);
645
- const res = getCoercion(typeId);
646
- if (!res)
647
- return payload;
648
- if (res.kind === "async") {
649
- throw new Error(`Async coercion required but convert used (${edgeLabel})`);
650
- }
651
- return res.convert(payload);
652
- },
653
- };
654
- }
655
479
  }
656
480
 
657
481
  /**
@@ -691,25 +515,30 @@ class EventEmitter {
691
515
  * RunContextManager component - manages run-context lifecycle
692
516
  */
693
517
  class RunContextManager {
694
- constructor() {
518
+ constructor(graph) {
519
+ this.graph = graph;
695
520
  this.runContexts = new Map();
696
521
  this.runContextCounter = 0;
522
+ this.graph = graph;
697
523
  }
698
524
  /**
699
525
  * Create a new run-context for runFromHere
700
526
  */
701
- createRunContext(startNodeId, options) {
527
+ createRunContext(startNodeId, resolve, options) {
702
528
  const id = `rc-${++this.runContextCounter}`;
703
529
  const ctx = {
704
530
  id,
705
531
  startNodes: new Set([startNodeId]),
706
532
  cancelledNodes: new Set(),
707
- pending: 0,
533
+ pendingNodes: 0,
534
+ pendingEdges: 0,
535
+ pendingResolvers: 0,
708
536
  skipPropagateValues: options?.skipPropagateValues ?? false,
709
537
  propagate: options?.propagate ?? true,
538
+ resolve,
710
539
  };
711
540
  this.runContexts.set(id, ctx);
712
- return ctx;
541
+ return id;
713
542
  }
714
543
  /**
715
544
  * Get a run-context by ID
@@ -729,18 +558,60 @@ class RunContextManager {
729
558
  hasActiveRunContexts() {
730
559
  return this.runContexts.size > 0;
731
560
  }
561
+ startNodeRun(id, nodeId) {
562
+ const ctx = this.runContexts.get(id);
563
+ if (!ctx)
564
+ return;
565
+ ctx.pendingNodes++;
566
+ }
567
+ finishNodeRun(id, nodeId) {
568
+ const ctx = this.runContexts.get(id);
569
+ if (!ctx)
570
+ return;
571
+ ctx.pendingNodes--;
572
+ this.finishRunContextIfPossible(id);
573
+ }
574
+ startEdgeConversion(id, edgeId) {
575
+ const ctx = this.runContexts.get(id);
576
+ if (!ctx)
577
+ return;
578
+ ctx.pendingEdges++;
579
+ }
580
+ finishEdgeConversion(id, edgeId) {
581
+ const ctx = this.runContexts.get(id);
582
+ if (!ctx)
583
+ return;
584
+ ctx.pendingEdges--;
585
+ this.finishRunContextIfPossible(id);
586
+ }
587
+ startHandleResolution(id, nodeId) {
588
+ const ctx = this.runContexts.get(id);
589
+ if (!ctx)
590
+ return;
591
+ ctx.pendingResolvers++;
592
+ }
593
+ finishHandleResolution(id, nodeId) {
594
+ const ctx = this.runContexts.get(id);
595
+ if (!ctx)
596
+ return;
597
+ ctx.pendingResolvers--;
598
+ this.finishRunContextIfPossible(id);
599
+ }
732
600
  /**
733
- * Finish and remove a run-context when its pending count reaches zero
601
+ * Finish and remove a run-context when all pending operations reach zero
734
602
  */
735
- finishRunContext(id, nodes) {
603
+ finishRunContextIfPossible(id) {
736
604
  const ctx = this.runContexts.get(id);
737
605
  if (!ctx)
738
606
  return;
739
- if (ctx.pending > 0)
607
+ if (ctx.pendingNodes > 0 ||
608
+ ctx.pendingEdges > 0 ||
609
+ ctx.pendingResolvers > 0) {
740
610
  return; // Still has pending work
611
+ }
741
612
  // Clean up activeRunContexts from all nodes
742
- for (const node of nodes.values()) {
743
- node.activeRunContexts.delete(id);
613
+ for (const node of this.graph.getNodes().values()) {
614
+ node.activeRunContextIds.delete(id);
744
615
  }
745
616
  this.runContexts.delete(id);
746
617
  if (ctx.resolve)
@@ -753,7 +624,7 @@ class RunContextManager {
753
624
  * @param edges - All edges in the graph (for downstream traversal)
754
625
  * @param nodes - All nodes in the graph (for clearing activeRunContexts)
755
626
  */
756
- cancelNodeInRunContexts(nodeId, includeDownstream, edges, nodes) {
627
+ cancelNodeInRunContexts(nodeId, includeDownstream) {
757
628
  const toCancel = new Set([nodeId]);
758
629
  if (includeDownstream) {
759
630
  // Collect all downstream nodes
@@ -764,7 +635,7 @@ class RunContextManager {
764
635
  if (visited.has(cur))
765
636
  continue;
766
637
  visited.add(cur);
767
- for (const e of edges) {
638
+ for (const e of this.graph.getEdges()) {
768
639
  if (e.source.nodeId === cur) {
769
640
  const targetId = e.target.nodeId;
770
641
  if (!visited.has(targetId)) {
@@ -783,9 +654,9 @@ class RunContextManager {
783
654
  }
784
655
  // Clear activeRunContexts for cancelled nodes
785
656
  for (const id of toCancel) {
786
- const node = nodes.get(id);
657
+ const node = this.graph.getNode(id);
787
658
  if (node)
788
- node.activeRunContexts.clear();
659
+ node.activeRunContextIds.clear();
789
660
  }
790
661
  }
791
662
  /**
@@ -813,17 +684,214 @@ const LOG_LEVEL_VALUES = {
813
684
  silent: 4,
814
685
  };
815
686
 
687
+ /**
688
+ * Shared utility functions for runtime components
689
+ */
690
+ /**
691
+ * Type guard to check if a value is a Promise
692
+ */
693
+ function isPromise(value) {
694
+ return !!value && typeof value.then === "function";
695
+ }
696
+ /**
697
+ * Unwrap a value that might be a Promise
698
+ */
699
+ async function unwrapMaybePromise(value) {
700
+ return isPromise(value) ? await value : value;
701
+ }
702
+ /**
703
+ * Shallow/deep-ish equality check to avoid unnecessary runs on identical values
704
+ */
705
+ function valuesEqual(a, b) {
706
+ if (a === b)
707
+ return true;
708
+ if (typeof a !== typeof b)
709
+ return false;
710
+ if (a && b && typeof a === "object") {
711
+ try {
712
+ return JSON.stringify(a) === JSON.stringify(b);
713
+ }
714
+ catch {
715
+ return false;
716
+ }
717
+ }
718
+ return false;
719
+ }
720
+
721
+ function tryHandleResolving(def, registry, environment) {
722
+ const out = new Map();
723
+ const pending = new Set();
724
+ for (const n of def.nodes) {
725
+ const desc = registry.nodes.get(n.typeId);
726
+ if (!desc)
727
+ continue;
728
+ const overrideInputs = n.resolvedHandles?.inputs;
729
+ const overrideOutputs = n.resolvedHandles?.outputs;
730
+ const overrideDefaults = n.resolvedHandles?.inputDefaults;
731
+ // Resolve dynamic handles if available (initial pass: inputs may be undefined)
732
+ let dyn = {};
733
+ try {
734
+ if (typeof desc.resolveHandles === "function") {
735
+ const maybe = desc.resolveHandles({
736
+ nodeId: n.nodeId,
737
+ environment: environment || {},
738
+ params: n.params,
739
+ inputs: undefined,
740
+ });
741
+ // Only use sync results here; async results are applied via recompute later
742
+ if (isPromise(maybe)) {
743
+ // mark node as pending async recompute
744
+ pending.add(n.nodeId);
745
+ }
746
+ else {
747
+ dyn = maybe || {};
748
+ }
749
+ }
750
+ }
751
+ catch {
752
+ // ignore dynamic resolution errors at this stage
753
+ }
754
+ // Merge base with dynamic and overrides (allow partial resolvedHandles)
755
+ const inputs = {
756
+ ...desc.inputs,
757
+ ...dyn.inputs,
758
+ ...overrideInputs,
759
+ };
760
+ const outputs = {
761
+ ...desc.outputs,
762
+ ...dyn.outputs,
763
+ ...overrideOutputs,
764
+ };
765
+ const inputDefaults = {
766
+ ...desc.inputDefaults,
767
+ ...dyn.inputDefaults,
768
+ ...overrideDefaults,
769
+ };
770
+ out.set(n.nodeId, { inputs, outputs, inputDefaults });
771
+ }
772
+ return { resolved: out, pending };
773
+ }
774
+ function buildEdges(def, registry, resolvedByNode) {
775
+ return def.edges.map((e) => {
776
+ const srcNode = def.nodes.find((n) => n.nodeId === e.source.nodeId);
777
+ const dstNode = def.nodes.find((n) => n.nodeId === e.target.nodeId);
778
+ const { srcDeclared, dstDeclared, effectiveTypeId } = extractEdgeTypes(e.source.nodeId, e.source.handle, e.target.nodeId, e.target.handle, resolvedByNode, e.typeId);
779
+ const { convert, convertAsync } = buildEdgeConverters(srcDeclared, dstDeclared, registry, `buildEdges: ${srcNode?.typeId || ""}.${e.source.nodeId}.${e.source.handle} -> ${dstNode?.typeId || ""}.${e.target.nodeId}.${e.target.handle}`);
780
+ return {
781
+ id: e.id,
782
+ source: { ...e.source },
783
+ target: { ...e.target },
784
+ typeId: e.typeId, // Preserve original (may be undefined)
785
+ effectiveTypeId, // Always present
786
+ convert,
787
+ convertAsync,
788
+ srcUnionTypes: Array.isArray(srcDeclared) ? [...srcDeclared] : undefined,
789
+ dstDeclared,
790
+ stats: { runs: 0, inFlight: false, progress: 0 },
791
+ };
792
+ });
793
+ }
794
+ /**
795
+ * Extract edge type information from resolved handles
796
+ * Used by both buildEdges and updateNodeHandles to avoid duplication
797
+ */
798
+ function extractEdgeTypes(sourceNodeId, sourceHandle, targetNodeId, targetHandle, resolvedByNode, explicitTypeId) {
799
+ const srcResolved = resolvedByNode.get(sourceNodeId);
800
+ const dstResolved = resolvedByNode.get(targetNodeId);
801
+ const srcDeclared = srcResolved
802
+ ? srcResolved.outputs[sourceHandle]
803
+ : undefined;
804
+ const dstDeclared = dstResolved
805
+ ? getInputTypeId(dstResolved.inputs, targetHandle)
806
+ : undefined;
807
+ let effectiveTypeId = explicitTypeId;
808
+ if (!effectiveTypeId) {
809
+ // Infer if not explicitly set
810
+ effectiveTypeId = Array.isArray(srcDeclared) ? srcDeclared[0] : srcDeclared;
811
+ }
812
+ return {
813
+ srcDeclared,
814
+ dstDeclared,
815
+ effectiveTypeId: effectiveTypeId ?? "untyped",
816
+ };
817
+ }
818
+ // Static helper: build edge converters for type coercion
819
+ function buildEdgeConverters(srcDeclared, dstDeclared, registry, edgeLabel) {
820
+ if (!dstDeclared || !srcDeclared) {
821
+ return {};
822
+ }
823
+ const isUnion = Array.isArray(srcDeclared);
824
+ const srcTypes = isUnion ? srcDeclared : [srcDeclared];
825
+ // Helper to get the coercion for a specific type
826
+ const getCoercion = (typeId) => {
827
+ return registry.resolveCoercion(typeId, dstDeclared);
828
+ };
829
+ // Resolve coercions for all source types
830
+ const coercions = srcTypes.map(getCoercion);
831
+ const hasAsync = coercions.some((r) => r?.kind === "async");
832
+ // Helper to extract and validate typed output for unions
833
+ const extractPayload = (v) => {
834
+ const typeId = getTypedOutputTypeId(v);
835
+ const payload = getTypedOutputValue(v);
836
+ if (isUnion) {
837
+ if (!typeId) {
838
+ throw new Error(`Typed output required for union source (${edgeLabel}); allowed: ${srcTypes.join("|")}`);
839
+ }
840
+ if (!srcTypes.includes(typeId)) {
841
+ throw new Error(`Invalid typed output ${typeId} (${edgeLabel}); allowed: ${srcTypes.join("|")}`);
842
+ }
843
+ }
844
+ else if (typeId) {
845
+ // Warn if typed output is used for non-union source
846
+ console.warn(`Typed output ${typeId} is fed even though source is not union (${edgeLabel}): ${srcDeclared} -> ${dstDeclared}`);
847
+ }
848
+ return { typeId: typeId || srcTypes[0], payload };
849
+ };
850
+ if (hasAsync) {
851
+ return {
852
+ convertAsync: async (v, signal) => {
853
+ const { typeId, payload } = extractPayload(v);
854
+ const res = getCoercion(typeId);
855
+ if (!res)
856
+ return payload;
857
+ if (res.kind === "async") {
858
+ return await res.convertAsync(payload, signal);
859
+ }
860
+ return res.convert(payload);
861
+ },
862
+ };
863
+ }
864
+ // Sync path
865
+ const firstCoercion = coercions.find((r) => r?.kind === "sync");
866
+ if (!firstCoercion) {
867
+ return {};
868
+ }
869
+ return {
870
+ convert: (v) => {
871
+ const { typeId, payload } = extractPayload(v);
872
+ const res = getCoercion(typeId);
873
+ if (!res)
874
+ return payload;
875
+ if (res.kind === "async") {
876
+ throw new Error(`Async coercion required but convert used (${edgeLabel})`);
877
+ }
878
+ return res.convert(payload);
879
+ },
880
+ };
881
+ }
882
+
816
883
  /**
817
884
  * HandleResolver component - manages dynamic handle resolution
818
885
  */
819
886
  class HandleResolver {
820
- constructor(graphStructure, eventEmitter, runtimeCoordinator, registry, environment) {
821
- this.recomputeTokenByNode = new Map();
822
- this.environment = {};
823
- this.graphStructure = graphStructure;
887
+ constructor(graph, eventEmitter, runContextManager, edgePropagator, registry, environment) {
888
+ this.graph = graph;
824
889
  this.eventEmitter = eventEmitter;
825
- this.runtimeCoordinator = runtimeCoordinator;
890
+ this.runContextManager = runContextManager;
891
+ this.edgePropagator = edgePropagator;
826
892
  this.registry = registry;
893
+ this.recomputeTokenByNode = new Map();
894
+ this.environment = {};
827
895
  this.environment = environment ?? {};
828
896
  }
829
897
  setRegistry(registry) {
@@ -839,106 +907,162 @@ class HandleResolver {
839
907
  // If no registry or node not found, skip
840
908
  if (!this.registry)
841
909
  return;
842
- const node = this.graphStructure.getNode(nodeId);
910
+ const node = this.graph.getNode(nodeId);
843
911
  if (!node)
844
912
  return;
913
+ // Track resolver start for all active run-contexts
914
+ if (node.activeRunContextIds && node.activeRunContextIds.size > 0) {
915
+ for (const runContextId of node.activeRunContextIds) {
916
+ this.runContextManager.startHandleResolution(runContextId, nodeId);
917
+ }
918
+ }
845
919
  setTimeout(() => {
846
- void this.recomputeHandlesForNode(nodeId);
920
+ void this.recomputeHandlesForNode(nodeId, node.activeRunContextIds);
847
921
  }, 0);
848
922
  }
849
- /**
850
- * Recompute dynamic handles for a single node using current inputs/environment
851
- */
852
- async recomputeHandlesForNode(nodeId) {
853
- const registry = this.registry;
854
- const node = this.graphStructure.getNode(nodeId);
855
- if (!node)
856
- return;
857
- const desc = registry.nodes.get(node.typeId);
858
- if (!desc)
923
+ // Update resolved handles for a single node and refresh edge converters/types that touch it
924
+ updateNodeHandles(nodeId, handles) {
925
+ if (!this.registry)
859
926
  return;
860
- const resolveHandles = desc.resolveHandles;
861
- if (typeof resolveHandles !== "function")
927
+ const node = this.graph.getNode(nodeId);
928
+ if (!node)
862
929
  return;
863
- const token = (this.recomputeTokenByNode.get(nodeId) ?? 0) + 1;
864
- this.recomputeTokenByNode.set(nodeId, token);
865
- // Log resolveHandles-start
866
- const nodeLogLevel = node.logLevel ?? "info";
867
- const nodeLogValue = LOG_LEVEL_VALUES[nodeLogLevel] ?? 1;
868
- const shouldLog = nodeLogValue <= LOG_LEVEL_VALUES.debug && nodeLogLevel !== "silent";
869
- if (shouldLog) {
870
- console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-start`);
871
- }
872
- let r;
873
- try {
874
- const res = resolveHandles({
875
- nodeId,
876
- environment: this.environment || {},
877
- params: node.params,
878
- inputs: node.inputs || {},
879
- });
880
- r = await unwrapMaybePromise(res);
881
- }
882
- catch {
883
- // Log resolveHandles-done even on error
884
- if (shouldLog) {
885
- console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-done (error)`);
930
+ this.graph.setResolvedHandles(nodeId, handles);
931
+ const edges = this.graph.getEdges();
932
+ const resolvedByNode = this.graph.getResolvedHandlesMap();
933
+ for (const e of edges) {
934
+ // Only update edges that touch the changed node
935
+ const touchesChangedNode = e.source.nodeId === nodeId || e.target.nodeId === nodeId;
936
+ if (!touchesChangedNode)
937
+ continue;
938
+ const srcNode = this.graph.getNode(e.source.nodeId);
939
+ const dstNode = this.graph.getNode(e.target.nodeId);
940
+ const oldDstDeclared = e.dstDeclared;
941
+ // Extract edge types using shared helper (handles both source and target updates)
942
+ const { srcDeclared, dstDeclared, effectiveTypeId } = extractEdgeTypes(e.source.nodeId, e.source.handle, e.target.nodeId, e.target.handle, resolvedByNode, e.typeId);
943
+ // Update edge properties
944
+ if (!e.typeId) {
945
+ e.effectiveTypeId = effectiveTypeId;
946
+ }
947
+ e.dstDeclared = dstDeclared;
948
+ e.srcUnionTypes = Array.isArray(srcDeclared)
949
+ ? [...srcDeclared]
950
+ : undefined;
951
+ // Update converters
952
+ const conv = buildEdgeConverters(srcDeclared, dstDeclared, this.registry, `updateNodeHandles: ${srcNode?.typeId || ""}.${e.source.nodeId}.${e.source.handle} -> ${dstNode?.typeId || ""}.${e.target.nodeId}.${e.target.handle}`);
953
+ e.convert = conv.convert;
954
+ e.convertAsync = conv.convertAsync;
955
+ if (e.target.nodeId === nodeId &&
956
+ oldDstDeclared === undefined &&
957
+ dstDeclared !== undefined) {
958
+ const srcNode = this.graph.getNode(e.source.nodeId);
959
+ if (srcNode) {
960
+ const srcValue = srcNode.outputs[e.source.handle];
961
+ if (srcValue !== undefined) {
962
+ this.edgePropagator.propagate(e.source.nodeId, e.source.handle, srcValue, srcNode.activeRunContextIds);
963
+ }
964
+ }
965
+ }
966
+ }
967
+ this.edgePropagator.invalidateDownstream(nodeId);
968
+ }
969
+ /**
970
+ * Recompute dynamic handles for a single node using current inputs/environment
971
+ */
972
+ async recomputeHandlesForNode(nodeId, activeRunContextIds) {
973
+ try {
974
+ if (!this.registry)
975
+ return;
976
+ const node = this.graph.getNode(nodeId);
977
+ if (!node)
978
+ return;
979
+ const desc = this.registry.nodes.get(node.typeId);
980
+ if (!desc)
981
+ return;
982
+ const resolveHandles = desc.resolveHandles;
983
+ if (typeof resolveHandles !== "function")
984
+ return;
985
+ const token = (this.recomputeTokenByNode.get(nodeId) ?? 0) + 1;
986
+ this.recomputeTokenByNode.set(nodeId, token);
987
+ // Log resolveHandles-start
988
+ const nodeLogLevel = node.logLevel ?? "info";
989
+ const nodeLogValue = LOG_LEVEL_VALUES[nodeLogLevel] ?? 1;
990
+ const shouldLog = nodeLogValue <= LOG_LEVEL_VALUES.debug && nodeLogLevel !== "silent";
991
+ if (shouldLog) {
992
+ console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-start`);
993
+ }
994
+ let resolved;
995
+ try {
996
+ const res = resolveHandles({
997
+ nodeId,
998
+ environment: this.environment || {},
999
+ params: node.params,
1000
+ inputs: node.inputs || {},
1001
+ });
1002
+ resolved = await unwrapMaybePromise(res);
1003
+ }
1004
+ catch {
1005
+ // Log resolveHandles-done even on error
1006
+ if (shouldLog) {
1007
+ console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-done (error)`);
1008
+ }
1009
+ return;
1010
+ }
1011
+ // Log resolveHandles-done
1012
+ if (shouldLog) {
1013
+ console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-done`);
1014
+ }
1015
+ // If a newer recompute was scheduled, drop this result
1016
+ if ((this.recomputeTokenByNode.get(nodeId) ?? 0) !== token)
1017
+ return;
1018
+ const before = this.graph.getResolvedHandles(nodeId);
1019
+ if (!before)
1020
+ return;
1021
+ // Re-fetch desc to ensure we have the latest (node might have been updated)
1022
+ const nodeDesc = this.registry.nodes.get(node.typeId);
1023
+ if (!nodeDesc)
1024
+ return;
1025
+ const inputs = { ...nodeDesc.inputs, ...resolved?.inputs };
1026
+ const outputs = { ...nodeDesc.outputs, ...resolved?.outputs };
1027
+ const inputDefaults = {
1028
+ ...nodeDesc.inputDefaults,
1029
+ ...resolved?.inputDefaults,
1030
+ };
1031
+ const after = { inputs, outputs, inputDefaults };
1032
+ // Compare shallow-structurally via JSON
1033
+ if (JSON.stringify(before) === JSON.stringify(after))
1034
+ return;
1035
+ // Call GraphRuntime's updateNodeHandles to update edges and re-propagate values
1036
+ // Note: updateNodeHandles will set the resolved handles internally
1037
+ this.updateNodeHandles(nodeId, after);
1038
+ // Notify graph updated with the changed handles
1039
+ this.eventEmitter.emit("invalidate", {
1040
+ reason: "graph-updated",
1041
+ resolvedHandles: { [nodeId]: after },
1042
+ });
1043
+ }
1044
+ finally {
1045
+ // Track resolver finish after successful completion
1046
+ if (activeRunContextIds && activeRunContextIds.size > 0) {
1047
+ for (const runContextId of activeRunContextIds) {
1048
+ this.runContextManager.finishHandleResolution(runContextId, nodeId);
1049
+ }
886
1050
  }
887
- return;
888
- }
889
- // Log resolveHandles-done
890
- if (shouldLog) {
891
- console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-done`);
892
1051
  }
893
- // If a newer recompute was scheduled, drop this result
894
- if ((this.recomputeTokenByNode.get(nodeId) ?? 0) !== token)
895
- return;
896
- const resolved = this.graphStructure.getResolvedHandles(nodeId);
897
- if (!resolved)
898
- return; // Node was deleted
899
- // Re-fetch desc to ensure we have the latest (node might have been updated)
900
- const nodeDesc = registry.nodes.get(node.typeId);
901
- if (!nodeDesc)
902
- return;
903
- const inputs = { ...nodeDesc.inputs, ...r?.inputs };
904
- const outputs = { ...nodeDesc.outputs, ...r?.outputs };
905
- const inputDefaults = {
906
- ...nodeDesc.inputDefaults,
907
- ...r?.inputDefaults,
908
- };
909
- const next = { inputs, outputs, inputDefaults };
910
- const before = resolved;
911
- // Compare shallow-structurally via JSON
912
- if (JSON.stringify(before) === JSON.stringify(next))
913
- return;
914
- this.graphStructure.setResolvedHandles(nodeId, next);
915
- // Call GraphRuntime's updateNodeHandles to update edges and re-propagate values
916
- this.runtimeCoordinator.updateNodeHandles(nodeId, next, registry);
917
- // Notify graph updated with the changed handles
918
- this.eventEmitter.emit("invalidate", {
919
- reason: "graph-updated",
920
- resolvedHandles: { [nodeId]: next },
921
- });
922
1052
  }
923
1053
  }
924
1054
 
925
1055
  /**
926
- * ValuePropagator component - handles value propagation through edges
1056
+ * EdgePropagator component - handles value propagation through edges
927
1057
  */
928
- class ValuePropagator {
929
- constructor(graphStructure, eventEmitter, runContextManager, handleResolver, runtimeCoordinator) {
930
- this.arrayInputBuckets = new Map();
931
- this.graphStructure = graphStructure;
1058
+ class EdgePropagator {
1059
+ constructor(graph, eventEmitter, runContextManager, handleResolver, nodeExecutor) {
1060
+ this.graph = graph;
932
1061
  this.eventEmitter = eventEmitter;
933
1062
  this.runContextManager = runContextManager;
934
1063
  this.handleResolver = handleResolver;
935
- this.runtimeCoordinator = runtimeCoordinator;
936
- }
937
- /**
938
- * Set the execution scheduler (called after construction to resolve circular dependency)
939
- */
940
- setExecutionScheduler(executionScheduler) {
941
- this.executionScheduler = executionScheduler;
1064
+ this.nodeExecutor = nodeExecutor;
1065
+ this.arrayInputBuckets = new Map();
942
1066
  }
943
1067
  /**
944
1068
  * Propagate value through edges
@@ -947,10 +1071,24 @@ class ValuePropagator {
947
1071
  */
948
1072
  propagate(srcNodeId, srcHandle, value, runContextIds) {
949
1073
  // Set source output
950
- const srcNode = this.graphStructure.getNode(srcNodeId);
1074
+ if (!this.setSourceOutput(srcNodeId, srcHandle, value)) {
1075
+ return; // Node was removed
1076
+ }
1077
+ // Find outgoing edges
1078
+ const outEdges = this.findOutgoingEdges(srcNodeId, srcHandle);
1079
+ // Process each edge
1080
+ for (const edge of outEdges) {
1081
+ this.propagateToEdge(edge, value, srcNodeId, runContextIds);
1082
+ }
1083
+ }
1084
+ /**
1085
+ * Set source output value and emit event
1086
+ */
1087
+ setSourceOutput(srcNodeId, srcHandle, value) {
1088
+ const srcNode = this.graph.getNode(srcNodeId);
951
1089
  if (!srcNode) {
952
1090
  // Node was removed (e.g., graph updated) but an async emit arrived late; ignore
953
- return;
1091
+ return false;
954
1092
  }
955
1093
  srcNode.outputs[srcHandle] = value;
956
1094
  this.eventEmitter.emit("value", {
@@ -960,221 +1098,306 @@ class ValuePropagator {
960
1098
  io: "output",
961
1099
  runtimeTypeId: getTypedOutputTypeId(value),
962
1100
  });
963
- // Fan-out along all edges from this output
964
- const edges = this.graphStructure.getEdges();
965
- const outEdges = edges.filter((e) => e.source.nodeId === srcNodeId && e.source.handle === srcHandle);
966
- const isRunContextAware = runContextIds !== undefined && runContextIds.size > 0;
967
- for (const e of outEdges) {
968
- // Filter run-contexts: skip any where src or dst is cancelled (only in run-context mode)
969
- let effectiveRunContexts;
970
- if (isRunContextAware) {
971
- effectiveRunContexts = new Set();
972
- for (const id of runContextIds) {
973
- const ctx = this.runContextManager.getRunContext(id);
974
- if (!ctx)
975
- continue;
976
- if (ctx.cancelledNodes.has(srcNodeId))
977
- continue;
978
- if (ctx.cancelledNodes.has(e.target.nodeId))
979
- continue;
980
- effectiveRunContexts.add(id);
981
- }
982
- if (effectiveRunContexts.size === 0)
983
- continue; // No valid run-contexts for this edge
984
- }
985
- // If source declares a union for this handle, require typed output
986
- const isUnion = Array.isArray(e.srcUnionTypes);
987
- const isTyped = isTypedOutput(value);
988
- if (isUnion && !isTyped) {
989
- const err = new Error(`Output ${srcNodeId}.${srcHandle} requires typed value for union output (allowed: ${e.srcUnionTypes.join("|")})`);
990
- this.eventEmitter.emit("error", {
991
- kind: "edge-convert",
992
- edgeId: e.id,
993
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
994
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
995
- err,
996
- });
1101
+ return true;
1102
+ }
1103
+ /**
1104
+ * Find all outgoing edges from a source node handle
1105
+ */
1106
+ findOutgoingEdges(srcNodeId, srcHandle) {
1107
+ const edges = this.graph.getEdges();
1108
+ return edges.filter((e) => e.source.nodeId === srcNodeId && e.source.handle === srcHandle);
1109
+ }
1110
+ /**
1111
+ * Propagate value to a single edge
1112
+ */
1113
+ propagateToEdge(edge, value, srcNodeId, runContextIds) {
1114
+ // Filter run-contexts
1115
+ const effectiveRunContexts = runContextIds && runContextIds.size > 0
1116
+ ? this.filterEffectiveRunContexts(edge, srcNodeId, runContextIds)
1117
+ : undefined;
1118
+ if (runContextIds &&
1119
+ runContextIds.size > 0 &&
1120
+ !(effectiveRunContexts && effectiveRunContexts.size > 0)) {
1121
+ return; // No valid run-contexts for this edge
1122
+ }
1123
+ // Validate union types
1124
+ if (!this.validateUnionType(edge, value, srcNodeId)) {
1125
+ return;
1126
+ }
1127
+ // Clone value per edge to isolate conversions
1128
+ let nextVal = structuredClone(value);
1129
+ // Apply conversion and propagate
1130
+ if (edge.convertAsync) {
1131
+ this.handleAsyncConversion(edge, nextVal, effectiveRunContexts);
1132
+ }
1133
+ else {
1134
+ this.handleSyncConversion(edge, nextVal, effectiveRunContexts);
1135
+ }
1136
+ }
1137
+ /**
1138
+ * Filter run-contexts to exclude cancelled nodes
1139
+ */
1140
+ filterEffectiveRunContexts(edge, srcNodeId, runContextIds) {
1141
+ const effectiveRunContexts = new Set();
1142
+ for (const id of runContextIds) {
1143
+ const ctx = this.runContextManager.getRunContext(id);
1144
+ if (!ctx)
997
1145
  continue;
1146
+ if (ctx.cancelledNodes.has(srcNodeId))
1147
+ continue;
1148
+ if (ctx.cancelledNodes.has(edge.target.nodeId))
1149
+ continue;
1150
+ effectiveRunContexts.add(id);
1151
+ }
1152
+ return effectiveRunContexts.size > 0 ? effectiveRunContexts : undefined;
1153
+ }
1154
+ /**
1155
+ * Validate union type requirements
1156
+ */
1157
+ validateUnionType(edge, value, srcNodeId) {
1158
+ const isUnion = Array.isArray(edge.srcUnionTypes);
1159
+ const isTyped = isTypedOutput(value);
1160
+ if (isUnion && !isTyped) {
1161
+ const err = new Error(`Output ${srcNodeId}.${edge.source.handle} requires typed value for union output (allowed: ${edge.srcUnionTypes.join("|")})`);
1162
+ this.eventEmitter.emit("error", {
1163
+ kind: "edge-convert",
1164
+ edgeId: edge.id,
1165
+ source: { nodeId: edge.source.nodeId, handle: edge.source.handle },
1166
+ target: { nodeId: edge.target.nodeId, handle: edge.target.handle },
1167
+ err,
1168
+ });
1169
+ return false;
1170
+ }
1171
+ return true;
1172
+ }
1173
+ /**
1174
+ * Handle synchronous conversion
1175
+ */
1176
+ handleSyncConversion(edge, value, effectiveRunContexts) {
1177
+ let convertedValue = value;
1178
+ if (edge.convert) {
1179
+ convertedValue = edge.convert(value);
1180
+ }
1181
+ this.applyToTarget(edge, convertedValue, effectiveRunContexts);
1182
+ }
1183
+ /**
1184
+ * Handle asynchronous conversion
1185
+ */
1186
+ handleAsyncConversion(edge, value, effectiveRunContexts) {
1187
+ if (!edge.convertAsync)
1188
+ return;
1189
+ // Track edge run-context IDs for pendingEdges tracking
1190
+ const edgeRunContextIds = effectiveRunContexts
1191
+ ? Array.from(effectiveRunContexts)
1192
+ : undefined;
1193
+ if (edgeRunContextIds) {
1194
+ for (const id of edgeRunContextIds) {
1195
+ this.runContextManager.startEdgeConversion(id, edge.id);
998
1196
  }
999
- // Clone per edge to isolate conversions
1000
- let nextVal = structuredClone(value);
1001
- const applyToTarget = (v) => {
1002
- const dstNode = this.graphStructure.getNode(e.target.nodeId);
1003
- if (!dstNode)
1004
- return;
1005
- // Skip writing to unresolved handles
1006
- if (e.dstDeclared === undefined)
1007
- return;
1008
- const dstIsArray = typeof e.dstDeclared === "string" && e.dstDeclared.endsWith("[]");
1009
- let next = v;
1010
- // Handle array types
1011
- if (dstIsArray) {
1012
- const toArray = (x) => Array.isArray(x) ? x : x === undefined ? [] : [x];
1013
- let forNode = this.arrayInputBuckets.get(e.target.nodeId);
1014
- if (!forNode) {
1015
- forNode = new Map();
1016
- this.arrayInputBuckets.set(e.target.nodeId, forNode);
1017
- }
1018
- let forHandle = forNode.get(e.target.handle);
1019
- if (!forHandle) {
1020
- forHandle = new Map();
1021
- forNode.set(e.target.handle, forHandle);
1022
- }
1023
- forHandle.set(e.id, toArray(v));
1024
- const merged = [];
1025
- for (const ed of edges) {
1026
- if (ed.target.nodeId === e.target.nodeId &&
1027
- ed.target.handle === e.target.handle) {
1028
- const part = forHandle.get(ed.id);
1029
- if (part && part.length)
1030
- merged.push(...part);
1031
- }
1032
- }
1033
- next = merged;
1034
- }
1035
- const prev = dstNode.inputs[e.target.handle];
1036
- const same = valuesEqual(prev, next);
1037
- if (!same) {
1038
- // Check skipPropagateValues (only in run-context mode)
1039
- let shouldSkipPropagateValues = false;
1040
- if (isRunContextAware && effectiveRunContexts) {
1041
- for (const id of effectiveRunContexts) {
1042
- const ctx = this.runContextManager.getRunContext(id);
1043
- if (ctx && ctx.skipPropagateValues) {
1044
- shouldSkipPropagateValues = true;
1045
- break;
1046
- }
1047
- }
1048
- }
1049
- // Set input values unless skipPropagateValues is enabled
1050
- if (!shouldSkipPropagateValues) {
1051
- dstNode.inputs[e.target.handle] = next;
1052
- this.eventEmitter.emit("value", {
1053
- nodeId: e.target.nodeId,
1054
- handle: e.target.handle,
1055
- value: next,
1056
- io: "input",
1057
- runtimeTypeId: getTypedOutputTypeId(next),
1058
- });
1059
- this.handleResolver.scheduleRecomputeHandles(e.target.nodeId);
1060
- }
1061
- // Check propagate flag (only in run-context mode; auto mode always propagates)
1062
- let shouldPropagate = true; // Default: always propagate in auto mode
1063
- if (isRunContextAware && effectiveRunContexts) {
1064
- shouldPropagate = false;
1065
- for (const id of effectiveRunContexts) {
1066
- const ctx = this.runContextManager.getRunContext(id);
1067
- if (ctx && ctx.propagate) {
1068
- shouldPropagate = true;
1069
- break;
1070
- }
1071
- }
1072
- }
1073
- // Schedule downstream execution if propagation is enabled
1074
- if (!this.runtimeCoordinator.isPaused() &&
1075
- shouldPropagate &&
1076
- this.executionScheduler.allInboundHaveValue(e.target.nodeId)) {
1077
- if (isRunContextAware && effectiveRunContexts) {
1078
- this.executionScheduler.scheduleInputsChangedWithRunContexts(e.target.nodeId, effectiveRunContexts);
1079
- }
1080
- else {
1081
- this.executionScheduler.scheduleInputsChanged(e.target.nodeId);
1082
- }
1083
- }
1084
- }
1085
- };
1086
- // Handle async conversion
1087
- if (e.convertAsync) {
1088
- this.eventEmitter.emit("stats", {
1089
- kind: "edge-start",
1090
- edgeId: e.id,
1091
- typeId: e.typeId,
1092
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
1093
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
1094
- });
1095
- const controller = new AbortController();
1096
- const startAt = Date.now();
1097
- e.stats.runs += 1;
1098
- e.stats.inFlight = true;
1099
- if (!isRunContextAware) {
1100
- // Only track progress in auto mode
1101
- e.stats.progress = 0;
1102
- }
1103
- const sig = controller.signal;
1104
- e.convertAsync(nextVal, sig)
1105
- .then((converted) => {
1106
- if (!sig.aborted) {
1107
- applyToTarget(converted);
1108
- e.stats.inFlight = false;
1109
- const duration = Date.now() - startAt;
1110
- e.stats.lastDurationMs = duration;
1111
- if (!isRunContextAware) {
1112
- // More detailed stats in auto mode
1113
- e.stats.lastEndAt = Date.now();
1114
- e.stats.lastError = undefined;
1115
- this.eventEmitter.emit("stats", {
1116
- kind: "edge-done",
1117
- edgeId: e.id,
1118
- typeId: e.typeId,
1119
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
1120
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
1121
- durationMs: duration,
1122
- });
1123
- }
1124
- }
1125
- })
1126
- .catch((err) => {
1127
- if (sig.aborted)
1128
- return;
1129
- e.stats.inFlight = false;
1130
- if (!isRunContextAware) {
1131
- e.stats.lastError = err;
1132
- }
1133
- this.eventEmitter.emit("error", {
1134
- kind: "edge-convert",
1135
- edgeId: e.id,
1136
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
1137
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
1138
- err,
1139
- });
1140
- });
1197
+ }
1198
+ this.eventEmitter.emit("stats", {
1199
+ kind: "edge-start",
1200
+ edgeId: edge.id,
1201
+ typeId: edge.typeId,
1202
+ source: { nodeId: edge.source.nodeId, handle: edge.source.handle },
1203
+ target: { nodeId: edge.target.nodeId, handle: edge.target.handle },
1204
+ });
1205
+ const controller = new AbortController();
1206
+ const startAt = Date.now();
1207
+ edge.stats.runs += 1;
1208
+ edge.stats.inFlight = true;
1209
+ edge.stats.progress = 0;
1210
+ edge
1211
+ .convertAsync(value, controller.signal)
1212
+ .then((converted) => {
1213
+ if (!controller.signal.aborted) {
1214
+ this.applyToTarget(edge, converted, effectiveRunContexts);
1215
+ this.updateEdgeStatsOnSuccess(edge, startAt);
1141
1216
  }
1142
- else {
1143
- // Synchronous conversion
1144
- if (e.convert) {
1145
- nextVal = e.convert(nextVal);
1146
- }
1147
- applyToTarget(nextVal);
1217
+ })
1218
+ .catch((err) => {
1219
+ if (controller.signal.aborted)
1220
+ return;
1221
+ this.handleEdgeConversionError(edge, err);
1222
+ })
1223
+ .finally(() => {
1224
+ this.finishEdgeConversion(edgeRunContextIds, edge.id);
1225
+ });
1226
+ }
1227
+ /**
1228
+ * Apply value to target node input
1229
+ */
1230
+ applyToTarget(edge, value, effectiveRunContexts) {
1231
+ const dstNode = this.graph.getNode(edge.target.nodeId);
1232
+ if (!dstNode)
1233
+ return;
1234
+ // Skip writing to unresolved handles
1235
+ if (edge.dstDeclared === undefined)
1236
+ return;
1237
+ // Handle array types
1238
+ const processedValue = this.processArrayInput(edge, value);
1239
+ // Check if value changed
1240
+ const prev = dstNode.inputs[edge.target.handle];
1241
+ if (valuesEqual(prev, processedValue)) {
1242
+ return; // No change
1243
+ }
1244
+ // Set input value (respecting skipPropagateValues)
1245
+ const shouldSetValue = this.shouldSetInputValue(effectiveRunContexts);
1246
+ if (shouldSetValue) {
1247
+ this.setTargetInput(edge, dstNode, processedValue);
1248
+ }
1249
+ // Schedule downstream execution
1250
+ this.executeDownstream(edge.target.nodeId, effectiveRunContexts);
1251
+ }
1252
+ /**
1253
+ * Process array input by merging values from all edges
1254
+ */
1255
+ processArrayInput(edge, value) {
1256
+ const dstIsArray = typeof edge.dstDeclared === "string" && edge.dstDeclared.endsWith("[]");
1257
+ if (!dstIsArray) {
1258
+ return value;
1259
+ }
1260
+ const toArray = (x) => Array.isArray(x) ? x : x === undefined ? [] : [x];
1261
+ let forNode = this.arrayInputBuckets.get(edge.target.nodeId);
1262
+ if (!forNode) {
1263
+ forNode = new Map();
1264
+ this.arrayInputBuckets.set(edge.target.nodeId, forNode);
1265
+ }
1266
+ let forHandle = forNode.get(edge.target.handle);
1267
+ if (!forHandle) {
1268
+ forHandle = new Map();
1269
+ forNode.set(edge.target.handle, forHandle);
1270
+ }
1271
+ forHandle.set(edge.id, toArray(value));
1272
+ // Merge all parts for this handle
1273
+ const edges = this.graph.getEdges();
1274
+ const merged = [];
1275
+ for (const ed of edges) {
1276
+ if (ed.target.nodeId === edge.target.nodeId &&
1277
+ ed.target.handle === edge.target.handle) {
1278
+ const part = forHandle.get(ed.id);
1279
+ if (part && part.length)
1280
+ merged.push(...part);
1148
1281
  }
1149
1282
  }
1283
+ return merged;
1150
1284
  }
1151
1285
  /**
1152
- * Propagate value in run-context aware mode (convenience method)
1286
+ * Check if input value should be set (respecting skipPropagateValues)
1153
1287
  */
1154
- propagateInRunContexts(srcNodeId, srcHandle, value, runContextIds) {
1155
- this.propagate(srcNodeId, srcHandle, value, runContextIds);
1288
+ shouldSetInputValue(effectiveRunContexts) {
1289
+ if (!effectiveRunContexts) {
1290
+ return true; // Auto mode always sets values
1291
+ }
1292
+ // Check skipPropagateValues (only in run-context mode)
1293
+ for (const id of effectiveRunContexts) {
1294
+ const ctx = this.runContextManager.getRunContext(id);
1295
+ if (ctx && ctx.skipPropagateValues) {
1296
+ return false;
1297
+ }
1298
+ }
1299
+ return true;
1300
+ }
1301
+ /**
1302
+ * Set target input value and emit event
1303
+ */
1304
+ setTargetInput(edge, dstNode, value) {
1305
+ dstNode.inputs[edge.target.handle] = value;
1306
+ this.eventEmitter.emit("value", {
1307
+ nodeId: edge.target.nodeId,
1308
+ handle: edge.target.handle,
1309
+ value,
1310
+ io: "input",
1311
+ runtimeTypeId: getTypedOutputTypeId(value),
1312
+ });
1313
+ this.handleResolver.scheduleRecomputeHandles(edge.target.nodeId);
1314
+ }
1315
+ /**
1316
+ * Execute downstream if conditions are met
1317
+ */
1318
+ executeDownstream(targetNodeId, effectiveRunContexts) {
1319
+ // Determine if we should propagate
1320
+ const shouldPropagate = this.shouldPropagateExecution(effectiveRunContexts);
1321
+ if (shouldPropagate && this.graph.allInboundHaveValue(targetNodeId)) {
1322
+ this.nodeExecutor.execute(targetNodeId, effectiveRunContexts);
1323
+ }
1324
+ }
1325
+ /**
1326
+ * Check if execution should propagate
1327
+ */
1328
+ shouldPropagateExecution(effectiveRunContexts) {
1329
+ if (!effectiveRunContexts) {
1330
+ return true; // Auto mode always propagates
1331
+ }
1332
+ // Check propagate flag (only in run-context mode)
1333
+ for (const id of effectiveRunContexts) {
1334
+ const ctx = this.runContextManager.getRunContext(id);
1335
+ if (ctx && ctx.propagate) {
1336
+ return true;
1337
+ }
1338
+ }
1339
+ return false;
1340
+ }
1341
+ /**
1342
+ * Update edge stats on successful conversion
1343
+ */
1344
+ updateEdgeStatsOnSuccess(edge, startAt) {
1345
+ edge.stats.inFlight = false;
1346
+ const duration = Date.now() - startAt;
1347
+ edge.stats.lastDurationMs = duration;
1348
+ this.eventEmitter.emit("stats", {
1349
+ kind: "edge-done",
1350
+ edgeId: edge.id,
1351
+ typeId: edge.typeId,
1352
+ source: { nodeId: edge.source.nodeId, handle: edge.source.handle },
1353
+ target: { nodeId: edge.target.nodeId, handle: edge.target.handle },
1354
+ durationMs: duration,
1355
+ });
1356
+ edge.stats.lastEndAt = Date.now();
1357
+ edge.stats.lastError = undefined;
1358
+ }
1359
+ /**
1360
+ * Handle edge conversion error
1361
+ */
1362
+ handleEdgeConversionError(edge, err) {
1363
+ edge.stats.inFlight = false;
1364
+ edge.stats.lastError = err;
1365
+ this.eventEmitter.emit("error", {
1366
+ kind: "edge-convert",
1367
+ edgeId: edge.id,
1368
+ source: { nodeId: edge.source.nodeId, handle: edge.source.handle },
1369
+ target: { nodeId: edge.target.nodeId, handle: edge.target.handle },
1370
+ err,
1371
+ });
1372
+ }
1373
+ /**
1374
+ * Finish edge conversion and decrement pending edges
1375
+ */
1376
+ finishEdgeConversion(edgeRunContextIds, edgeId) {
1377
+ if (!edgeRunContextIds)
1378
+ return;
1379
+ for (const id of edgeRunContextIds) {
1380
+ this.runContextManager.finishEdgeConversion(id, edgeId);
1381
+ }
1156
1382
  }
1157
1383
  /**
1158
1384
  * Re-emit all outputs from a node (used when graph updates)
1159
1385
  * Only re-emits outputs that are valid according to resolved handles
1160
1386
  */
1161
- reemitNodeOutputs(nodeId) {
1162
- const node = this.graphStructure.getNode(nodeId);
1387
+ invalidateDownstream(nodeId) {
1388
+ const node = this.graph.getNode(nodeId);
1163
1389
  if (!node)
1164
1390
  return;
1165
1391
  // Get resolved handles to filter out invalid outputs
1166
- const resolved = this.graphStructure.getResolvedHandles(nodeId);
1392
+ const resolved = this.graph.getResolvedHandles(nodeId);
1167
1393
  const validOutputHandles = resolved?.outputs
1168
1394
  ? new Set(Object.keys(resolved.outputs))
1169
1395
  : new Set();
1170
1396
  // Use node's activeRunContexts to propagate to new nodes that were added
1171
- const runContextIds = node.activeRunContexts.size > 0
1172
- ? new Set(node.activeRunContexts)
1173
- : undefined;
1174
1397
  for (const [handle, value] of Object.entries(node.outputs)) {
1175
1398
  // Only re-emit if this handle is still valid
1176
1399
  if (validOutputHandles.has(handle)) {
1177
- this.propagate(nodeId, handle, value, runContextIds);
1400
+ this.propagate(nodeId, handle, value, node.activeRunContextIds);
1178
1401
  }
1179
1402
  }
1180
1403
  }
@@ -1193,52 +1416,35 @@ class ValuePropagator {
1193
1416
  }
1194
1417
 
1195
1418
  /**
1196
- * ExecutionScheduler component - handles node execution scheduling and lifecycle
1419
+ * NodeExecutor component - handles node execution scheduling and lifecycle
1197
1420
  */
1198
- class ExecutionScheduler {
1199
- constructor(graphStructure, eventEmitter, runContextManager, valuePropagator, runtimeCoordinator, environment) {
1200
- this.environment = {};
1201
- this.graphStructure = graphStructure;
1421
+ class NodeExecutor {
1422
+ constructor(graph, eventEmitter, runContextManager, edgePropagator, runtime, environment) {
1423
+ this.graph = graph;
1202
1424
  this.eventEmitter = eventEmitter;
1203
1425
  this.runContextManager = runContextManager;
1204
- this.valuePropagator = valuePropagator;
1205
- this.runtimeCoordinator = runtimeCoordinator;
1426
+ this.edgePropagator = edgePropagator;
1427
+ this.runtime = runtime;
1428
+ this.environment = {};
1206
1429
  this.environment = environment ?? {};
1207
1430
  }
1208
1431
  setEnvironment(environment) {
1209
1432
  this.environment = environment;
1210
1433
  }
1211
- /**
1212
- * Check if all inbound edges for a node have values
1213
- */
1214
- allInboundHaveValue(nodeId) {
1215
- const node = this.graphStructure.getNode(nodeId);
1216
- if (!node)
1217
- return false;
1218
- const edges = this.graphStructure.getEdges();
1219
- const inbound = edges.filter((e) => e.target.nodeId === nodeId);
1220
- if (inbound.length === 0)
1221
- return true;
1222
- for (const e of inbound) {
1223
- if (!(e.target.handle in node.inputs))
1224
- return false;
1225
- }
1226
- return true;
1227
- }
1228
1434
  /**
1229
1435
  * Compute effective inputs for a node by merging real inputs with defaults
1230
1436
  */
1231
1437
  getEffectiveInputs(nodeId) {
1232
- const node = this.graphStructure.getNode(nodeId);
1438
+ const node = this.graph.getNode(nodeId);
1233
1439
  if (!node)
1234
1440
  return {};
1235
- const registry = this.graphStructure.getRegistry();
1441
+ const registry = this.graph.getRegistry();
1236
1442
  if (!registry)
1237
1443
  return {};
1238
1444
  const desc = registry.nodes.get(node.typeId);
1239
1445
  if (!desc)
1240
1446
  return {};
1241
- const resolved = this.graphStructure.getResolvedHandles(nodeId);
1447
+ const resolved = this.graph.getResolvedHandles(nodeId);
1242
1448
  const regDefaults = desc.inputDefaults ?? {};
1243
1449
  const dynDefaults = resolved?.inputDefaults ?? {};
1244
1450
  // Identify which handles are dynamically resolved (not in registry statics)
@@ -1252,7 +1458,7 @@ class ExecutionScheduler {
1252
1458
  // Start with real inputs only (no defaults)
1253
1459
  const effective = { ...node.inputs };
1254
1460
  // Build set of inbound handles (wired inputs)
1255
- const edges = this.graphStructure.getEdges();
1461
+ const edges = this.graph.getEdges();
1256
1462
  const inbound = new Set(edges
1257
1463
  .filter((e) => e.target.nodeId === nodeId)
1258
1464
  .map((e) => e.target.handle));
@@ -1279,7 +1485,7 @@ class ExecutionScheduler {
1279
1485
  createExecutionContext(nodeId, node, inputs, runId, abortSignal, runContextIds, options) {
1280
1486
  const emitHandler = options?.emitHandler ??
1281
1487
  ((handle, value) => {
1282
- this.valuePropagator.propagate(nodeId, handle, value, runContextIds);
1488
+ this.edgePropagator.propagate(nodeId, handle, value, runContextIds);
1283
1489
  });
1284
1490
  const reportProgress = options?.reportProgress ??
1285
1491
  ((p) => {
@@ -1314,20 +1520,18 @@ class ExecutionScheduler {
1314
1520
  }
1315
1521
  }
1316
1522
  };
1317
- // Store run-context IDs for use in scheduleInputsChanged
1318
- const storedRunContextIds = runContextIds;
1319
1523
  return {
1320
1524
  nodeId,
1321
1525
  state: node.state,
1322
1526
  setState: (next) => Object.assign(node.state, next),
1323
1527
  emit: emitHandler,
1324
1528
  invalidateDownstream: () => {
1325
- this.runtimeCoordinator.invalidateDownstream(nodeId);
1529
+ this.edgePropagator.invalidateDownstream(nodeId);
1326
1530
  },
1327
- scheduleInputsChanged: () => {
1328
- if (this.allInboundHaveValue(nodeId)) {
1531
+ execute: () => {
1532
+ if (this.graph.allInboundHaveValue(nodeId)) {
1329
1533
  // Preserve run-context IDs when scheduling from execution context
1330
- this.scheduleInputsChangedInternal(nodeId, storedRunContextIds);
1534
+ this.execute(nodeId, runContextIds);
1331
1535
  }
1332
1536
  },
1333
1537
  getInput: (handle) => inputs[handle],
@@ -1339,232 +1543,345 @@ class ExecutionScheduler {
1339
1543
  };
1340
1544
  }
1341
1545
  /**
1342
- * Schedule a node for execution when its inputs change
1546
+ * Internal method for executing inputs changed (also used by GraphRuntime)
1547
+ */
1548
+ execute(nodeId, runContextIds) {
1549
+ const node = this.graph.getNode(nodeId);
1550
+ if (!node)
1551
+ return;
1552
+ const runMode = this.runtime.getRunMode();
1553
+ if (!runMode) {
1554
+ console.warn("NodeExecutor.execute: no runMode, skipping execution");
1555
+ return;
1556
+ }
1557
+ if (runMode === "manual" && (!runContextIds || runContextIds.size === 0)) {
1558
+ console.warn("NodeExecutor.execute: no runContextIds provided, skipping execution");
1559
+ return;
1560
+ }
1561
+ if (runMode === "auto" && runContextIds && runContextIds.size > 0) {
1562
+ console.warn("NodeExecutor.execute: runContextIds provided in auto-mode, ignoring");
1563
+ runContextIds = undefined;
1564
+ }
1565
+ // Early validation for auto-mode paused state
1566
+ if (this.runtime.isPaused())
1567
+ return;
1568
+ // Attach run-context IDs if provided
1569
+ this.attachRunContexts(node, runContextIds);
1570
+ // Handle debouncing
1571
+ const now = Date.now();
1572
+ if (this.shouldDebounce(node, now)) {
1573
+ this.handleDebouncedSchedule(node, nodeId, now);
1574
+ return;
1575
+ }
1576
+ // Prepare execution plan
1577
+ const executionPlan = this.prepareExecutionPlan(node, nodeId, runContextIds, now);
1578
+ // Route to appropriate concurrency handler
1579
+ this.routeToConcurrencyHandler(node, nodeId, executionPlan);
1580
+ }
1581
+ /**
1582
+ * Attach run-context IDs to the node
1583
+ */
1584
+ attachRunContexts(node, runContextIds) {
1585
+ if (!runContextIds)
1586
+ return;
1587
+ node.activeRunContextIds = new Set([
1588
+ ...node.activeRunContextIds,
1589
+ ...runContextIds,
1590
+ ]);
1591
+ }
1592
+ /**
1593
+ * Check if execution should be debounced
1594
+ */
1595
+ shouldDebounce(node, now) {
1596
+ const policy = node.policy ?? {};
1597
+ return !!(policy.debounceMs &&
1598
+ node.lastScheduledAt &&
1599
+ now - node.lastScheduledAt < policy.debounceMs);
1600
+ }
1601
+ /**
1602
+ * Handle debounced scheduling by replacing the latest queued item
1603
+ */
1604
+ handleDebouncedSchedule(node, nodeId, now) {
1605
+ const effectiveInputs = this.getEffectiveInputs(nodeId);
1606
+ node.queue.splice(0, node.queue.length);
1607
+ node.runSeq += 1;
1608
+ const rid = `${nodeId}:${node.runSeq}:${now}`;
1609
+ node.queue.push({ runId: rid, inputs: effectiveInputs });
1610
+ }
1611
+ /**
1612
+ * Prepare execution plan with all necessary information
1613
+ */
1614
+ prepareExecutionPlan(node, nodeId, runContextIds, now) {
1615
+ node.lastScheduledAt = now;
1616
+ node.runSeq += 1;
1617
+ const runId = `${nodeId}:${node.runSeq}:${now}`;
1618
+ node.latestRunId = runId;
1619
+ const effectiveInputs = this.getEffectiveInputs(nodeId);
1620
+ // Take a shallow snapshot of the current policy for this run
1621
+ const policySnapshot = node.policy ? { ...node.policy } : undefined;
1622
+ return {
1623
+ runId,
1624
+ effectiveInputs,
1625
+ runContextIdsForRun: runContextIds,
1626
+ timestamp: now,
1627
+ policy: policySnapshot,
1628
+ };
1629
+ }
1630
+ /**
1631
+ * Route execution to appropriate concurrency handler
1632
+ */
1633
+ routeToConcurrencyHandler(node, nodeId, plan) {
1634
+ const mode = plan.policy?.asyncConcurrency ?? "switch";
1635
+ switch (mode) {
1636
+ case "drop":
1637
+ this.handleDropMode(node, nodeId, plan);
1638
+ break;
1639
+ case "queue":
1640
+ this.handleQueueMode(node, nodeId, plan);
1641
+ break;
1642
+ case "switch":
1643
+ case "merge":
1644
+ default:
1645
+ this.startRun(node, nodeId, plan);
1646
+ break;
1647
+ }
1648
+ }
1649
+ /**
1650
+ * Handle drop mode - drop execution if node is already running, otherwise start run
1651
+ */
1652
+ handleDropMode(node, nodeId, plan) {
1653
+ // Drop if node is already running
1654
+ if (node.activeControllers.size > 0) {
1655
+ return; // Don't increment pendingCount if we're dropping this run
1656
+ }
1657
+ // Start run if node is not running
1658
+ this.startRun(node, nodeId, plan);
1659
+ }
1660
+ /**
1661
+ * Handle queue mode - add to queue and process sequentially
1662
+ */
1663
+ handleQueueMode(node, nodeId, plan) {
1664
+ const maxQ = plan.policy?.maxQueue ?? 8;
1665
+ node.queue.push({ runId: plan.runId, inputs: plan.effectiveInputs });
1666
+ if (node.queue.length > maxQ)
1667
+ node.queue.shift();
1668
+ this.processQueue(node, nodeId);
1669
+ }
1670
+ /**
1671
+ * Process queued executions sequentially
1672
+ */
1673
+ processQueue(node, nodeId) {
1674
+ const processNext = () => {
1675
+ if (node.activeControllers.size > 0)
1676
+ return;
1677
+ const next = node.queue.shift();
1678
+ if (!next)
1679
+ return;
1680
+ node.latestRunId = next.runId;
1681
+ const policySnapshot = node.policy ? { ...node.policy } : undefined;
1682
+ const plan = {
1683
+ runId: next.runId,
1684
+ effectiveInputs: next.inputs,
1685
+ runContextIdsForRun: node.activeRunContextIds,
1686
+ timestamp: Date.now(),
1687
+ policy: policySnapshot,
1688
+ };
1689
+ this.startRun(node, nodeId, plan, () => {
1690
+ setTimeout(processNext, 0);
1691
+ });
1692
+ };
1693
+ processNext();
1694
+ }
1695
+ /**
1696
+ * Start a node execution run
1697
+ */
1698
+ startRun(node, nodeId, plan, onDone) {
1699
+ // Track run-contexts
1700
+ this.trackRunContextStart(nodeId, plan.runContextIdsForRun);
1701
+ // Setup execution controller
1702
+ const controller = this.createExecutionController(node, plan.runId);
1703
+ // Handle concurrency mode
1704
+ this.applyConcurrencyMode(node, controller, plan);
1705
+ // Setup timeout if needed
1706
+ const timeoutId = this.setupTimeout(node, controller, plan);
1707
+ // Create execution context
1708
+ const ctx = this.createExecutionContext(nodeId, node, plan.effectiveInputs, plan.runId, controller.signal, plan.runContextIdsForRun, this.createEmitAndProgressHandlers(node, nodeId, plan));
1709
+ // Execute
1710
+ this.executeNode(node, nodeId, ctx, plan, controller, timeoutId, onDone);
1711
+ }
1712
+ /**
1713
+ * Track run-context start for pending nodes
1714
+ */
1715
+ trackRunContextStart(nodeId, runContextIdsForRun) {
1716
+ if (runContextIdsForRun && runContextIdsForRun.size > 0) {
1717
+ for (const id of runContextIdsForRun) {
1718
+ this.runContextManager.startNodeRun(id, nodeId);
1719
+ }
1720
+ }
1721
+ }
1722
+ /**
1723
+ * Create execution controller and update node stats
1724
+ */
1725
+ createExecutionController(node, runId) {
1726
+ const controller = new AbortController();
1727
+ node.stats.runs += 1;
1728
+ node.stats.active += 1;
1729
+ node.stats.lastStartAt = Date.now();
1730
+ node.stats.progress = 0;
1731
+ node.activeControllers.add(controller);
1732
+ node.controllerRunIds.set(controller, runId);
1733
+ return controller;
1734
+ }
1735
+ /**
1736
+ * Apply concurrency mode (switch mode aborts other controllers)
1737
+ */
1738
+ applyConcurrencyMode(node, controller, plan) {
1739
+ const mode = plan.policy?.asyncConcurrency ?? "switch";
1740
+ if (mode === "switch") {
1741
+ for (const c of Array.from(node.activeControllers)) {
1742
+ if (c !== controller)
1743
+ c.abort("switch");
1744
+ }
1745
+ }
1746
+ }
1747
+ /**
1748
+ * Setup timeout for execution if configured
1749
+ */
1750
+ setupTimeout(node, controller, plan) {
1751
+ const policy = plan.policy ?? {};
1752
+ if (policy.timeoutMs && policy.timeoutMs > 0) {
1753
+ return setTimeout(() => controller.abort("timeout"), policy.timeoutMs);
1754
+ }
1755
+ return undefined;
1756
+ }
1757
+ /**
1758
+ * Create emit and progress handlers for execution context
1759
+ */
1760
+ createEmitAndProgressHandlers(node, nodeId, plan) {
1761
+ const policy = plan.policy ?? {};
1762
+ return {
1763
+ emitHandler: (handle, value) => {
1764
+ const m = policy.asyncConcurrency ?? "switch";
1765
+ // Drop emits from runs that were explicitly cancelled due to a
1766
+ // snapshot/undo/redo operation, regardless of asyncConcurrency.
1767
+ if (node.snapshotCancelledRunIds?.has(plan.runId))
1768
+ return;
1769
+ if (m !== "merge" && plan.runId !== node.latestRunId)
1770
+ return;
1771
+ this.edgePropagator.propagate(nodeId, handle, value, plan.runContextIdsForRun);
1772
+ },
1773
+ reportProgress: (p) => {
1774
+ node.stats.progress = Math.max(0, Math.min(1, Number(p) || 0));
1775
+ this.eventEmitter.emit("stats", {
1776
+ kind: "node-progress",
1777
+ nodeId,
1778
+ typeId: node.typeId,
1779
+ runId: plan.runId,
1780
+ progress: node.stats.progress,
1781
+ });
1782
+ },
1783
+ };
1784
+ }
1785
+ /**
1786
+ * Execute the node with retry logic and cleanup
1787
+ */
1788
+ executeNode(node, nodeId, ctx, plan, controller, timeoutId, onDone) {
1789
+ // Fire node-start event
1790
+ this.eventEmitter.emit("stats", {
1791
+ kind: "node-start",
1792
+ nodeId,
1793
+ typeId: node.typeId,
1794
+ runId: plan.runId,
1795
+ });
1796
+ ctx.log("debug", "node-start");
1797
+ const exec = async (attempt) => {
1798
+ let hadError = false;
1799
+ try {
1800
+ if (node.lifecycle?.prepare) {
1801
+ ctx.log("debug", "prepare-start");
1802
+ node.lifecycle.prepare(node.params ?? {}, ctx);
1803
+ ctx.log("debug", "prepare-done");
1804
+ }
1805
+ await node.runtime.onInputsChanged?.(plan.effectiveInputs, ctx);
1806
+ }
1807
+ catch (err) {
1808
+ // Suppress errors caused by expected cancellations
1809
+ if (controller.signal.aborted) {
1810
+ const reason = controller.signal.reason;
1811
+ if (reason === "switch" ||
1812
+ reason === "snapshot" ||
1813
+ reason === "node-deleted" ||
1814
+ reason === "user-cancelled") {
1815
+ return; // Cancellation events are emitted separately, skip error handling
1816
+ }
1817
+ }
1818
+ hadError = true;
1819
+ node.stats.lastError = err;
1820
+ const retry = plan.policy?.retry;
1821
+ if (retry && attempt < (retry.attempts ?? 0)) {
1822
+ const delay = retry.backoffMs ? retry.backoffMs(attempt) : 0;
1823
+ await new Promise((r) => setTimeout(r, delay));
1824
+ return exec(attempt + 1);
1825
+ }
1826
+ this.eventEmitter.emit("error", {
1827
+ kind: "node-run",
1828
+ nodeId,
1829
+ runId: plan.runId,
1830
+ err,
1831
+ });
1832
+ }
1833
+ finally {
1834
+ this.cleanupExecution(node, nodeId, ctx, plan, controller, timeoutId, hadError, onDone);
1835
+ }
1836
+ };
1837
+ exec(0);
1838
+ }
1839
+ /**
1840
+ * Cleanup after execution completes
1343
1841
  */
1344
- scheduleInputsChanged(nodeId) {
1345
- this.scheduleInputsChangedInternal(nodeId);
1346
- }
1347
- /**
1348
- * Schedule a node for execution with run-context IDs
1349
- */
1350
- scheduleInputsChangedWithRunContexts(nodeId, runContextIds) {
1351
- this.scheduleInputsChangedInternal(nodeId, runContextIds);
1352
- }
1353
- /**
1354
- * Internal method for scheduling (also used by GraphRuntime)
1355
- */
1356
- scheduleInputsChangedInternal(nodeId, runContextIds) {
1357
- const node = this.graphStructure.getNode(nodeId);
1358
- if (!node)
1359
- return;
1360
- if (this.runtimeCoordinator.isPaused())
1361
- return;
1362
- // If run-context IDs are provided, attach them to the node
1363
- if (runContextIds) {
1364
- for (const id of runContextIds) {
1365
- node.activeRunContexts.add(id);
1842
+ cleanupExecution(node, nodeId, ctx, plan, controller, timeoutId, hadError, onDone) {
1843
+ // Decrement pendingNodes count for all relevant run-contexts
1844
+ if (plan.runContextIdsForRun && plan.runContextIdsForRun.size > 0) {
1845
+ for (const id of plan.runContextIdsForRun) {
1846
+ this.runContextManager.finishNodeRun(id, nodeId);
1366
1847
  }
1367
1848
  }
1368
- const now = Date.now();
1369
- const policy = node.policy ?? {};
1370
- // Compute effective inputs (real inputs + defaults) for this execution
1371
- const effectiveInputs = this.getEffectiveInputs(nodeId);
1372
- if (policy.debounceMs &&
1373
- node.lastScheduledAt &&
1374
- now - node.lastScheduledAt < policy.debounceMs) {
1375
- // debounce: replace latest queued
1376
- node.queue.splice(0, node.queue.length);
1377
- node.runSeq += 1;
1378
- const rid = `${nodeId}:${node.runSeq}:${now}`;
1379
- node.queue.push({ runId: rid, inputs: effectiveInputs });
1849
+ // Skip cleanup if node was deleted (cleanup already handled)
1850
+ if (!this.graph.hasNode(nodeId)) {
1380
1851
  return;
1381
1852
  }
1382
- node.lastScheduledAt = now;
1383
- node.runSeq += 1;
1384
- const rid = `${nodeId}:${node.runSeq}:${now}`;
1385
- node.latestRunId = rid;
1386
- // Get run-context IDs for this node (use provided or from node's activeRunContexts)
1387
- const runContextIdsForRun = runContextIds ||
1388
- (node.activeRunContexts.size > 0
1389
- ? new Set(node.activeRunContexts)
1390
- : undefined);
1391
- const startRun = (runId, capturedInputs, runContextIdsForRun, onDone) => {
1392
- // Increment pending count for all relevant run-contexts (node will run)
1393
- if (runContextIdsForRun && runContextIdsForRun.size > 0) {
1394
- for (const id of runContextIdsForRun) {
1395
- const ctx = this.runContextManager.getRunContext(id);
1396
- if (ctx)
1397
- ctx.pending++;
1398
- }
1399
- }
1400
- const controller = new AbortController();
1401
- node.stats.runs += 1;
1402
- node.stats.active += 1;
1403
- node.stats.lastStartAt = now;
1404
- node.stats.progress = 0;
1405
- node.activeControllers.add(controller);
1406
- node.controllerRunIds.set(controller, runId);
1407
- const mode = policy.asyncConcurrency ?? "switch";
1408
- if (mode === "switch") {
1409
- for (const c of Array.from(node.activeControllers)) {
1410
- if (c !== controller)
1411
- c.abort("switch");
1412
- }
1413
- }
1414
- let timeoutId;
1415
- if (policy.timeoutMs && policy.timeoutMs > 0) {
1416
- timeoutId = setTimeout(() => controller.abort("timeout"), policy.timeoutMs);
1417
- }
1418
- const ctx = this.createExecutionContext(nodeId, node, capturedInputs, runId, controller.signal, runContextIdsForRun, {
1419
- emitHandler: (handle, value) => {
1420
- const m = policy.asyncConcurrency ?? "switch";
1421
- // Drop emits from runs that were explicitly cancelled due to a
1422
- // snapshot/undo/redo operation, regardless of asyncConcurrency.
1423
- if (node.snapshotCancelledRunIds?.has(runId))
1424
- return;
1425
- if (m !== "merge" && runId !== node.latestRunId)
1426
- return;
1427
- this.valuePropagator.propagate(nodeId, handle, value, runContextIdsForRun);
1428
- },
1429
- reportProgress: (p) => {
1430
- node.stats.progress = Math.max(0, Math.min(1, Number(p) || 0));
1431
- this.eventEmitter.emit("stats", {
1432
- kind: "node-progress",
1433
- nodeId,
1434
- typeId: node.typeId,
1435
- runId,
1436
- progress: node.stats.progress,
1437
- });
1438
- },
1439
- });
1440
- const exec = async (attempt) => {
1441
- let hadError = false;
1442
- try {
1443
- if (node.lifecycle?.prepare) {
1444
- ctx.log("debug", "prepare-start");
1445
- node.lifecycle.prepare(node.params ?? {}, ctx);
1446
- ctx.log("debug", "prepare-done");
1447
- }
1448
- await node.runtime.onInputsChanged?.(capturedInputs, ctx);
1449
- }
1450
- catch (err) {
1451
- // Suppress errors caused by expected cancellations
1452
- if (controller.signal.aborted) {
1453
- const reason = controller.signal.reason;
1454
- if (reason === "switch" ||
1455
- reason === "snapshot" ||
1456
- reason === "node-deleted" ||
1457
- reason === "user-cancelled") {
1458
- return; // Cancellation events are emitted separately, skip error handling
1459
- }
1460
- }
1461
- hadError = true;
1462
- node.stats.lastError = err;
1463
- const retry = policy.retry;
1464
- if (retry && attempt < (retry.attempts ?? 0)) {
1465
- const delay = retry.backoffMs ? retry.backoffMs(attempt) : 0;
1466
- await new Promise((r) => setTimeout(r, delay));
1467
- return exec(attempt + 1);
1468
- }
1469
- this.eventEmitter.emit("error", {
1470
- kind: "node-run",
1471
- nodeId,
1472
- runId,
1473
- err,
1474
- });
1475
- }
1476
- finally {
1477
- // Skip cleanup if node was deleted (cleanup already handled)
1478
- if (!this.graphStructure.hasNode(nodeId)) {
1479
- return;
1480
- }
1481
- if (timeoutId)
1482
- clearTimeout(timeoutId);
1483
- node.activeControllers.delete(controller);
1484
- node.controllerRunIds.delete(controller);
1485
- node.stats.active = Math.max(0, node.activeControllers.size);
1486
- node.stats.lastEndAt = Date.now();
1487
- node.stats.lastDurationMs =
1488
- node.stats.lastStartAt && node.stats.lastEndAt
1489
- ? node.stats.lastEndAt - node.stats.lastStartAt
1490
- : undefined;
1491
- if (!hadError)
1492
- node.stats.lastError = undefined;
1493
- // Only emit node-done if not cancelled (cancellation events emitted separately)
1494
- const isCancelled = controller.signal.aborted &&
1495
- (controller.signal.reason === "snapshot" ||
1496
- controller.signal.reason === "node-deleted" ||
1497
- controller.signal.reason === "user-cancelled");
1498
- if (!isCancelled) {
1499
- this.eventEmitter.emit("stats", {
1500
- kind: "node-done",
1501
- nodeId,
1502
- typeId: node.typeId,
1503
- runId,
1504
- durationMs: node.stats.lastDurationMs,
1505
- });
1506
- }
1507
- ctx.log("debug", "node-done", {
1508
- durationMs: node.stats.lastDurationMs,
1509
- hadError,
1510
- });
1511
- // Decrement pending count for all relevant run-contexts
1512
- if (runContextIdsForRun && runContextIdsForRun.size > 0) {
1513
- for (const id of runContextIdsForRun) {
1514
- const ctx = this.runContextManager.getRunContext(id);
1515
- if (ctx) {
1516
- ctx.pending--;
1517
- if (ctx.pending === 0) {
1518
- this.runContextManager.finishRunContext(id, this.graphStructure.getNodes());
1519
- }
1520
- }
1521
- }
1522
- }
1523
- if (onDone)
1524
- onDone();
1525
- }
1526
- };
1527
- // Fire node-start event
1853
+ if (timeoutId)
1854
+ clearTimeout(timeoutId);
1855
+ node.activeControllers.delete(controller);
1856
+ node.controllerRunIds.delete(controller);
1857
+ node.stats.active = Math.max(0, node.activeControllers.size);
1858
+ node.stats.lastEndAt = Date.now();
1859
+ node.stats.lastDurationMs =
1860
+ node.stats.lastStartAt && node.stats.lastEndAt
1861
+ ? node.stats.lastEndAt - node.stats.lastStartAt
1862
+ : undefined;
1863
+ if (!hadError)
1864
+ node.stats.lastError = undefined;
1865
+ // Only emit node-done if not cancelled (cancellation events emitted separately)
1866
+ const isCancelled = controller.signal.aborted &&
1867
+ (controller.signal.reason === "snapshot" ||
1868
+ controller.signal.reason === "node-deleted" ||
1869
+ controller.signal.reason === "user-cancelled");
1870
+ if (!isCancelled) {
1528
1871
  this.eventEmitter.emit("stats", {
1529
- kind: "node-start",
1872
+ kind: "node-done",
1530
1873
  nodeId,
1531
1874
  typeId: node.typeId,
1532
- runId,
1875
+ runId: plan.runId,
1876
+ durationMs: node.stats.lastDurationMs,
1533
1877
  });
1534
- ctx.log("debug", "node-start");
1535
- exec(0);
1536
- };
1537
- const mode = policy.asyncConcurrency ?? "switch";
1538
- if (mode === "drop" && node.activeControllers.size > 0) {
1539
- // Don't increment pendingCount if we're dropping this run
1540
- return;
1541
- }
1542
- if (mode === "queue") {
1543
- const maxQ = policy.maxQueue ?? 8;
1544
- node.queue.push({ runId: rid, inputs: effectiveInputs });
1545
- if (node.queue.length > maxQ)
1546
- node.queue.shift();
1547
- const processNext = () => {
1548
- if (node.activeControllers.size > 0)
1549
- return;
1550
- const next = node.queue.shift();
1551
- if (!next)
1552
- return;
1553
- node.latestRunId = next.runId;
1554
- // Use node's activeRunContexts for queued items
1555
- const queuedRunContextIds = node.activeRunContexts.size > 0
1556
- ? new Set(node.activeRunContexts)
1557
- : undefined;
1558
- startRun(next.runId, next.inputs, queuedRunContextIds, () => {
1559
- // After finishing, schedule next
1560
- setTimeout(processNext, 0);
1561
- });
1562
- };
1563
- processNext();
1564
- return;
1565
1878
  }
1566
- // switch or merge
1567
- startRun(rid, effectiveInputs, runContextIdsForRun);
1879
+ ctx.log("debug", "node-done", {
1880
+ durationMs: node.stats.lastDurationMs,
1881
+ hadError,
1882
+ });
1883
+ if (onDone)
1884
+ onDone();
1568
1885
  }
1569
1886
  /**
1570
1887
  * Cancel all active runs for a node
@@ -1612,7 +1929,7 @@ class ExecutionScheduler {
1612
1929
  const toCancel = new Set(nodeIds);
1613
1930
  const visited = new Set();
1614
1931
  const queue = [...nodeIds];
1615
- const edges = this.graphStructure.getEdges();
1932
+ const edges = this.graph.getEdges();
1616
1933
  // Collect all downstream nodes to cancel
1617
1934
  for (let i = 0; i < queue.length; i++) {
1618
1935
  const nodeId = queue[i];
@@ -1631,7 +1948,7 @@ class ExecutionScheduler {
1631
1948
  }
1632
1949
  // Cancel runs for all affected nodes
1633
1950
  for (const nodeId of toCancel) {
1634
- const node = this.graphStructure.getNode(nodeId);
1951
+ const node = this.graph.getNode(nodeId);
1635
1952
  if (!node)
1636
1953
  continue;
1637
1954
  this.cancelNodeActiveRuns(node, reason);
@@ -1642,8 +1959,8 @@ class ExecutionScheduler {
1642
1959
  }
1643
1960
  // Cancel nodes in run-contexts (exclude them from active run-contexts)
1644
1961
  for (const nodeId of toCancel) {
1645
- this.runContextManager.cancelNodeInRunContexts(nodeId, false, // includeDownstream = false (already collected above)
1646
- edges, this.graphStructure.getNodes());
1962
+ // includeDownstream = false (already collected above)
1963
+ this.runContextManager.cancelNodeInRunContexts(nodeId, false);
1647
1964
  }
1648
1965
  }
1649
1966
  }
@@ -1652,45 +1969,30 @@ class ExecutionScheduler {
1652
1969
  class GraphRuntime {
1653
1970
  constructor() {
1654
1971
  // State
1655
- this.paused = false;
1656
1972
  this.environment = {};
1973
+ this.runMode = null;
1974
+ this.pauseRefCount = 0;
1657
1975
  // Initialize components
1658
- this.graphStructure = new GraphStructure();
1976
+ this.graph = new Graph();
1659
1977
  this.eventEmitter = new EventEmitter();
1660
- this.runContextManager = new RunContextManager();
1661
- // Initialize components with interface-based dependencies
1662
- // HandleResolver only needs IRuntimeCoordinator (this)
1663
- this.handleResolver = new HandleResolver(this.graphStructure, this.eventEmitter, this, // IRuntimeCoordinator
1664
- undefined, // registry set later
1665
- undefined // environment set later
1666
- );
1667
- // ValuePropagator needs IHandleResolver and IRuntimeCoordinator
1668
- // ExecutionScheduler needs IValuePropagator - circular dependency!
1669
- // Solution: Create ValuePropagator first (without executionScheduler),
1670
- // then create ExecutionScheduler with ValuePropagator,
1671
- // then wire ValuePropagator with ExecutionScheduler via setExecutionScheduler
1672
- this.valuePropagator = new ValuePropagator(this.graphStructure, this.eventEmitter, this.runContextManager, this.handleResolver, // IHandleResolver
1673
- this // IRuntimeCoordinator
1674
- );
1675
- // Create ExecutionScheduler with ValuePropagator
1676
- this.executionScheduler = new ExecutionScheduler(this.graphStructure, this.eventEmitter, this.runContextManager, this.valuePropagator, // IValuePropagator
1677
- this, // IRuntimeCoordinator
1678
- this.environment);
1679
- // Wire ValuePropagator with ExecutionScheduler to resolve circular dependency
1680
- this.valuePropagator.setExecutionScheduler(this.executionScheduler);
1978
+ this.runContextManager = new RunContextManager(this.graph);
1979
+ this.handleResolver = new HandleResolver(this.graph, this.eventEmitter, this.runContextManager, this);
1980
+ this.edgePropagator = new EdgePropagator(this.graph, this.eventEmitter, this.runContextManager, this.handleResolver, this);
1981
+ // Create NodeExecutor with EdgePropagator
1982
+ this.nodeExecutor = new NodeExecutor(this.graph, this.eventEmitter, this.runContextManager, this, this);
1681
1983
  }
1682
1984
  static create(def, registry, opts) {
1683
1985
  const gr = new GraphRuntime();
1684
1986
  gr.environment = opts?.environment ?? {};
1685
1987
  // Set registry and environment on components
1686
- gr.graphStructure.setRegistry(registry);
1988
+ gr.graph.setRegistry(registry);
1687
1989
  gr.handleResolver.setRegistry(registry);
1688
1990
  gr.handleResolver.setEnvironment(gr.environment);
1689
- gr.executionScheduler.setEnvironment(gr.environment);
1991
+ gr.nodeExecutor.setEnvironment(gr.environment);
1690
1992
  // Precompute per-node resolved handles (use def-provided overrides; do not compute dynamically here)
1691
- const initial = GraphStructure.computeResolvedHandleMap(def, registry, gr.environment);
1692
- for (const [nodeId, handles] of initial.map) {
1693
- gr.graphStructure.setResolvedHandles(nodeId, handles);
1993
+ const initial = tryHandleResolving(def, registry, gr.environment);
1994
+ for (const [nodeId, handles] of initial.resolved) {
1995
+ gr.graph.setResolvedHandles(nodeId, handles);
1694
1996
  }
1695
1997
  // Instantiate nodes
1696
1998
  for (const n of def.nodes) {
@@ -1731,13 +2033,13 @@ class GraphRuntime {
1731
2033
  queued: 0,
1732
2034
  progress: 0,
1733
2035
  },
1734
- activeRunContexts: new Set(),
2036
+ activeRunContextIds: new Set(),
1735
2037
  };
1736
- gr.graphStructure.setNode(n.nodeId, rn);
2038
+ gr.graph.setNode(n.nodeId, rn);
1737
2039
  }
1738
2040
  // Instantiate edges
1739
- const edges = GraphStructure.buildEdges(def, registry, gr.graphStructure.getResolvedHandlesMap());
1740
- gr.graphStructure.setEdges(edges);
2041
+ const edges = buildEdges(def, registry, gr.graph.getResolvedHandlesMap());
2042
+ gr.graph.setEdges(edges);
1741
2043
  // Schedule async recompute only for nodes that indicated Promise-based resolveHandles
1742
2044
  for (const nodeId of initial.pending) {
1743
2045
  gr.handleResolver.scheduleRecomputeHandles(nodeId);
@@ -1748,12 +2050,12 @@ class GraphRuntime {
1748
2050
  return this.eventEmitter.on(event, handler);
1749
2051
  }
1750
2052
  setInputs(nodeId, inputs) {
1751
- const node = this.graphStructure.getNode(nodeId);
2053
+ const node = this.graph.getNode(nodeId);
1752
2054
  if (!node)
1753
2055
  throw new Error(`Node not found: ${nodeId}`);
1754
2056
  let anyChanged = false;
1755
- const edges = this.graphStructure.getEdges();
1756
- const registry = this.graphStructure.getRegistry();
2057
+ const edges = this.graph.getEdges();
2058
+ const registry = this.graph.getRegistry();
1757
2059
  for (const [handle, value] of Object.entries(inputs)) {
1758
2060
  const hasInbound = edges.some((e) => e.target.nodeId === nodeId && e.target.handle === handle);
1759
2061
  if (hasInbound)
@@ -1761,7 +2063,7 @@ class GraphRuntime {
1761
2063
  // Validate input value against declared type
1762
2064
  if (value !== undefined && registry) {
1763
2065
  const desc = registry.nodes.get(node.typeId);
1764
- const resolved = this.graphStructure.getResolvedHandles(nodeId);
2066
+ const resolved = this.graph.getResolvedHandles(nodeId);
1765
2067
  // Get typeId from resolved handles first, then registry statics
1766
2068
  const typeId = resolved
1767
2069
  ? getInputTypeId(resolved.inputs, handle)
@@ -1800,100 +2102,23 @@ class GraphRuntime {
1800
2102
  anyChanged = true;
1801
2103
  }
1802
2104
  }
1803
- if (!this.paused) {
1804
- // Only schedule if all inbound inputs are present (or there are none)
1805
- if (anyChanged && this.executionScheduler.allInboundHaveValue(nodeId))
1806
- this.executionScheduler.scheduleInputsChangedInternal(nodeId);
1807
- // Recompute dynamic handles for this node when its direct inputs change
1808
- if (anyChanged)
1809
- this.handleResolver.scheduleRecomputeHandles(nodeId);
2105
+ // In auto mode, input updates can trigger execution; in manual mode they never should.
2106
+ if (anyChanged) {
2107
+ this.handleResolver.scheduleRecomputeHandles(nodeId);
2108
+ if (this.runMode === "auto" && this.graph.allInboundHaveValue(nodeId)) {
2109
+ this.execute(nodeId);
2110
+ }
1810
2111
  }
1811
2112
  }
1812
2113
  getOutput(nodeId, output) {
1813
- const node = this.graphStructure.getNode(nodeId);
2114
+ const node = this.graph.getNode(nodeId);
1814
2115
  return node?.outputs[output];
1815
2116
  }
1816
- // Update resolved handles for a single node and refresh edge converters/types that touch it
1817
- updateNodeHandles(nodeId, handles, registry) {
1818
- const node = this.graphStructure.getNode(nodeId);
1819
- if (!node)
1820
- return;
1821
- const oldResolved = this.graphStructure.getResolvedHandles(nodeId);
1822
- this.graphStructure.setResolvedHandles(nodeId, handles);
1823
- // Clear outputs that are no longer valid handles
1824
- const oldOutputs = oldResolved?.outputs ?? {};
1825
- const newOutputs = handles.outputs ?? {};
1826
- const oldOutputHandles = new Set(Object.keys(oldOutputs));
1827
- const newOutputHandles = new Set(Object.keys(newOutputs));
1828
- for (const handle of oldOutputHandles) {
1829
- if (!newOutputHandles.has(handle)) {
1830
- // Output handle was removed - clear it and emit undefined to invalidate downstream
1831
- delete node.outputs[handle];
1832
- this.eventEmitter.emit("value", {
1833
- nodeId,
1834
- handle,
1835
- value: undefined,
1836
- io: "output",
1837
- });
1838
- }
1839
- }
1840
- const edges = this.graphStructure.getEdges();
1841
- // Recompute edge converter/type for edges where this node is source or target
1842
- for (const e of edges) {
1843
- const srcNode = this.graphStructure.getNode(e.source.nodeId);
1844
- const dstNode = this.graphStructure.getNode(e.target.nodeId);
1845
- let srcDeclared = e.effectiveTypeId; // Use effectiveTypeId as fallback
1846
- let dstDeclared = e.dstDeclared;
1847
- const oldDstDeclared = dstDeclared; // Track old value to detect resolution
1848
- if (e.source.nodeId === nodeId) {
1849
- const resolved = this.graphStructure.getResolvedHandles(nodeId);
1850
- srcDeclared = resolved
1851
- ? resolved.outputs[e.source.handle]
1852
- : srcDeclared;
1853
- // Update effectiveTypeId if original wasn't explicit
1854
- if (!e.typeId) {
1855
- e.effectiveTypeId = Array.isArray(srcDeclared)
1856
- ? srcDeclared?.[0] ?? "untyped"
1857
- : srcDeclared ?? "untyped";
1858
- }
1859
- }
1860
- if (e.target.nodeId === nodeId) {
1861
- const resolved = this.graphStructure.getResolvedHandles(nodeId);
1862
- if (resolved) {
1863
- dstDeclared = getInputTypeId(resolved.inputs, e.target.handle);
1864
- e.dstDeclared = dstDeclared;
1865
- }
1866
- }
1867
- const conv = GraphStructure.buildEdgeConverters(srcDeclared, dstDeclared, registry, `updateNodeHandles: ${srcNode?.typeId || ""}.${e.source.nodeId}.${e.source.handle} -> ${dstNode?.typeId || ""}.${e.target.nodeId}.${e.target.handle}`);
1868
- e.convert = conv.convert;
1869
- e.convertAsync = conv.convertAsync;
1870
- // If target handle was just resolved (was undefined, now has a type), re-propagate values
1871
- if (e.target.nodeId === nodeId &&
1872
- oldDstDeclared === undefined &&
1873
- dstDeclared !== undefined) {
1874
- const srcNode = this.graphStructure.getNode(e.source.nodeId);
1875
- if (srcNode) {
1876
- const srcValue = srcNode.outputs[e.source.handle];
1877
- if (srcValue !== undefined) {
1878
- // Re-propagate through the now-resolved edge converter
1879
- // Preserve run-contexts if source node has them
1880
- const runContextIds = srcNode.activeRunContexts.size > 0
1881
- ? new Set(srcNode.activeRunContexts)
1882
- : undefined;
1883
- this.valuePropagator.propagate(e.source.nodeId, e.source.handle, srcValue, runContextIds);
1884
- }
1885
- }
1886
- }
1887
- }
1888
- // Re-emit only valid outputs (after clearing removed ones)
1889
- this.valuePropagator.reemitNodeOutputs(nodeId);
1890
- }
1891
2117
  launch(invalidate = false) {
1892
- // call onActivated for nodes that implement it
1893
- for (const node of this.graphStructure.getNodes().values()) {
1894
- const effectiveInputs = this.executionScheduler.getEffectiveInputs(node.nodeId);
2118
+ for (const node of this.graph.getNodes().values()) {
2119
+ const effectiveInputs = this.nodeExecutor.getEffectiveInputs(node.nodeId);
1895
2120
  const ctrl = new AbortController();
1896
- const ctx = this.executionScheduler.createExecutionContext(node.nodeId, node, effectiveInputs, `${node.nodeId}:init`, ctrl.signal);
2121
+ const ctx = this.nodeExecutor.createExecutionContext(node.nodeId, node, effectiveInputs, `${node.nodeId}:init`, ctrl.signal);
1897
2122
  if (node.lifecycle?.prepare) {
1898
2123
  ctx.log("debug", "prepare-start");
1899
2124
  node.lifecycle.prepare(node.params ?? {}, ctx);
@@ -1901,40 +2126,27 @@ class GraphRuntime {
1901
2126
  }
1902
2127
  node.runtime.onActivated?.();
1903
2128
  }
1904
- if (invalidate) {
1905
- // After activation, schedule nodes that have all inbound inputs present
1906
- for (const nodeId of this.graphStructure.getNodes().keys()) {
1907
- if (this.executionScheduler.allInboundHaveValue(nodeId))
1908
- this.executionScheduler.scheduleInputsChangedInternal(nodeId);
2129
+ if (this.runMode === "auto" && invalidate) {
2130
+ for (const nodeId of this.graph.getNodes().keys()) {
2131
+ if (this.graph.allInboundHaveValue(nodeId))
2132
+ this.execute(nodeId);
1909
2133
  }
1910
2134
  }
1911
2135
  }
1912
2136
  triggerExternal(nodeId, event) {
1913
- const node = this.graphStructure.getNode(nodeId);
2137
+ const node = this.graph.getNode(nodeId);
1914
2138
  if (!node)
1915
2139
  return;
1916
- // Forward event to node's onExternalEvent handler for custom actions
1917
2140
  node.runtime.onExternalEvent?.(event, node.state);
1918
2141
  }
1919
- dispose() {
1920
- // Resolve all pending run-context promises before cleanup
1921
- this.runContextManager.resolveAll();
1922
- for (const node of this.graphStructure.getNodes().values()) {
1923
- node.runtime.onDeactivated?.();
1924
- node.runtime.dispose?.();
1925
- node.lifecycle?.dispose?.({
1926
- state: node.state,
1927
- setState: (next) => Object.assign(node.state, next),
1928
- });
1929
- }
1930
- this.graphStructure.clear();
2142
+ cancelNodeRuns(nodeIds) {
2143
+ this.nodeExecutor.cancelNodeRuns(nodeIds);
1931
2144
  }
1932
2145
  getNodeIds() {
1933
- return Array.from(this.graphStructure.getNodes().keys());
2146
+ return Array.from(this.graph.getNodes().keys());
1934
2147
  }
1935
- // Unsafe helpers for serializer: read-only accessors and hydration
1936
2148
  getNodeData(nodeId) {
1937
- const node = this.graphStructure.getNode(nodeId);
2149
+ const node = this.graph.getNode(nodeId);
1938
2150
  if (!node)
1939
2151
  return undefined;
1940
2152
  return {
@@ -1951,16 +2163,14 @@ class GraphRuntime {
1951
2163
  setEnvironment(env) {
1952
2164
  this.environment = { ...env };
1953
2165
  this.handleResolver.setEnvironment(this.environment);
1954
- this.executionScheduler.setEnvironment(this.environment);
1955
- // Recompute dynamic handles for all nodes when environment changes
1956
- for (const nodeId of this.graphStructure.getNodes().keys()) {
2166
+ this.nodeExecutor.setEnvironment(this.environment);
2167
+ for (const nodeId of this.graph.getNodes().keys()) {
1957
2168
  this.handleResolver.scheduleRecomputeHandles(nodeId);
1958
2169
  }
1959
2170
  }
1960
- // Export a GraphDefinition reflecting the current runtime view
1961
2171
  getGraphDef() {
1962
- const nodes = Array.from(this.graphStructure.getNodes().values()).map((n) => {
1963
- const resolved = this.graphStructure.getResolvedHandles(n.nodeId);
2172
+ const nodes = Array.from(this.graph.getNodes().values()).map((n) => {
2173
+ const resolved = this.graph.getResolvedHandles(n.nodeId);
1964
2174
  return {
1965
2175
  nodeId: n.nodeId,
1966
2176
  typeId: n.typeId,
@@ -1968,18 +2178,15 @@ class GraphRuntime {
1968
2178
  resolvedHandles: resolved ? { ...resolved } : undefined,
1969
2179
  };
1970
2180
  });
1971
- const edges = this.graphStructure
1972
- .getEdges()
1973
- .map((e) => ({
2181
+ const edges = this.graph.getEdges().map((e) => ({
1974
2182
  id: e.id,
1975
2183
  source: { nodeId: e.source.nodeId, handle: e.source.handle },
1976
2184
  target: { nodeId: e.target.nodeId, handle: e.target.handle },
1977
- typeId: e.typeId, // Only export original typeId (may be undefined)
2185
+ typeId: e.typeId,
1978
2186
  }));
1979
2187
  return { nodes, edges };
1980
2188
  }
1981
2189
  async whenIdle() {
1982
- // If we have active run-contexts, wait for all of them to complete
1983
2190
  const allRunContexts = this.runContextManager.getAllRunContexts();
1984
2191
  if (allRunContexts.size > 0) {
1985
2192
  await new Promise((resolve) => {
@@ -1995,7 +2202,7 @@ class GraphRuntime {
1995
2202
  });
1996
2203
  }
1997
2204
  const isIdle = () => {
1998
- for (const n of this.graphStructure.getNodes().values()) {
2205
+ for (const n of this.graph.getNodes().values()) {
1999
2206
  if (n.activeControllers.size > 0)
2000
2207
  return false;
2001
2208
  if (n.queue.length > 0)
@@ -2015,97 +2222,51 @@ class GraphRuntime {
2015
2222
  setTimeout(check, 10);
2016
2223
  });
2017
2224
  }
2018
- /**
2019
- * Run this node and optionally all dynamically reachable downstream nodes as a run-context.
2020
- * Includes nodes added later behind the same path (via re-emits).
2021
- * @param startNodeId - The node to start execution from
2022
- * @param options - Execution options
2023
- * @param options.skipPropagateValues - If true, don't set inputs of linked nodes (default: false)
2024
- * @param options.propagate - If false, don't schedule downstream nodes (default: true)
2025
- */
2026
2225
  async runFromHereContext(startNodeId, options) {
2027
- const node = this.graphStructure.getNode(startNodeId);
2028
- if (!node) {
2029
- // Node doesn't exist - resolve immediately
2030
- return;
2031
- }
2032
- const ctx = this.runContextManager.createRunContext(startNodeId, options);
2033
- // Create promise that resolves when context finishes
2034
- const promise = new Promise((resolve) => {
2035
- ctx.resolve = resolve;
2036
- });
2037
- // Temporarily unpause if needed
2038
- const wasPaused = this.paused;
2039
- if (wasPaused) {
2040
- this.paused = false;
2041
- }
2042
- try {
2043
- // Seed the start node with this run-context
2044
- node.activeRunContexts.add(ctx.id);
2045
- this.scheduleInputsChangedWithRunContexts(startNodeId, new Set([ctx.id]));
2046
- await promise;
2047
- }
2048
- finally {
2049
- // Restore pause state if it was paused and no other run-contexts are active
2050
- if (wasPaused && this.runContextManager.getAllRunContexts().size === 0) {
2051
- this.paused = true;
2052
- }
2053
- }
2054
- }
2055
- /**
2056
- * Schedule a node with run-context IDs attached
2057
- */
2058
- scheduleInputsChangedWithRunContexts(nodeId, runContextIds) {
2059
- const node = this.graphStructure.getNode(nodeId);
2226
+ const node = this.graph.getNode(startNodeId);
2060
2227
  if (!node)
2061
2228
  return;
2062
- // Attach run-contexts to the node
2063
- for (const id of runContextIds) {
2064
- node.activeRunContexts.add(id);
2065
- }
2066
- this.executionScheduler.scheduleInputsChangedInternal(nodeId, runContextIds);
2067
- }
2068
- pause() {
2069
- this.paused = true;
2070
- }
2071
- isPaused() {
2072
- return this.paused;
2229
+ return new Promise((resolve) => {
2230
+ const id = this.runContextManager.createRunContext(startNodeId, resolve, options);
2231
+ node.activeRunContextIds.add(id);
2232
+ this.execute(startNodeId, new Set([id]));
2233
+ });
2073
2234
  }
2074
- resume() {
2075
- this.paused = false;
2235
+ setRunMode(runMode) {
2236
+ this.runMode = runMode;
2076
2237
  }
2077
- invalidateDownstream(nodeId) {
2078
- this.valuePropagator.reemitNodeOutputs(nodeId);
2238
+ getRunMode() {
2239
+ return this.runMode;
2079
2240
  }
2080
- scheduleInputsChanged(nodeId) {
2081
- this.executionScheduler.scheduleInputsChangedInternal(nodeId);
2241
+ requestPause() {
2242
+ this.pauseRefCount++;
2243
+ let released = false;
2244
+ return () => {
2245
+ if (released)
2246
+ return;
2247
+ released = true;
2248
+ this.pauseRefCount--;
2249
+ };
2082
2250
  }
2083
- cancelNodeRuns(nodeIds) {
2084
- this.executionScheduler.cancelNodeRuns(nodeIds);
2251
+ isPaused() {
2252
+ return this.pauseRefCount > 0;
2085
2253
  }
2086
2254
  copyOutputs(fromNodeId, toNodeId, options) {
2087
- // Get outputs from source node
2088
2255
  const fromNode = this.getNodeData(fromNodeId);
2089
2256
  if (!fromNode?.outputs)
2090
2257
  return;
2091
- // Copy outputs to target node using hydrate
2092
- // hydrate already pauses internally, so we don't need to handle dry option here
2093
- // reemit: !options?.dry means don't propagate downstream if dry mode
2094
- this.hydrate({ outputs: { [toNodeId]: { ...fromNode.outputs } } }, { reemit: !options?.dry });
2258
+ this.hydrate({ outputs: { [toNodeId]: { ...fromNode.outputs } } }, { invalidate: !options?.dry });
2095
2259
  }
2096
- // Hydrate inputs/outputs without triggering computation; optionally re-emit outputs downstream
2097
2260
  hydrate(payload, opts) {
2098
- const prevPaused = this.paused;
2099
- this.paused = true;
2261
+ const releasePause = this.requestPause();
2100
2262
  try {
2101
2263
  const ins = payload?.inputs || {};
2102
2264
  for (const [nodeId, map] of Object.entries(ins)) {
2103
- const node = this.graphStructure.getNode(nodeId);
2265
+ const node = this.graph.getNode(nodeId);
2104
2266
  if (!node)
2105
2267
  continue;
2106
2268
  for (const [h, v] of Object.entries(map || {})) {
2107
2269
  node.inputs[h] = structuredClone(v);
2108
- // emit input value event
2109
2270
  this.eventEmitter.emit("value", {
2110
2271
  nodeId,
2111
2272
  handle: h,
@@ -2117,12 +2278,11 @@ class GraphRuntime {
2117
2278
  }
2118
2279
  const outs = payload?.outputs || {};
2119
2280
  for (const [nodeId, map] of Object.entries(outs)) {
2120
- const node = this.graphStructure.getNode(nodeId);
2281
+ const node = this.graph.getNode(nodeId);
2121
2282
  if (!node)
2122
2283
  continue;
2123
2284
  for (const [h, v] of Object.entries(map || {})) {
2124
2285
  node.outputs[h] = structuredClone(v);
2125
- // emit output value event
2126
2286
  this.eventEmitter.emit("value", {
2127
2287
  nodeId,
2128
2288
  handle: h,
@@ -2132,238 +2292,245 @@ class GraphRuntime {
2132
2292
  });
2133
2293
  }
2134
2294
  }
2135
- if (opts?.reemit) {
2136
- for (const nodeId of this.graphStructure.getNodes().keys()) {
2137
- this.valuePropagator.reemitNodeOutputs(nodeId);
2295
+ if (opts?.invalidate) {
2296
+ for (const nodeId of this.graph.getNodes().keys()) {
2297
+ this.invalidateDownstream(nodeId);
2138
2298
  }
2139
2299
  }
2140
2300
  }
2141
2301
  finally {
2142
- this.paused = prevPaused;
2302
+ releasePause();
2143
2303
  }
2144
2304
  }
2145
- // Incrementally update nodes/edges to match new definition without full rebuild
2146
2305
  update(def, registry) {
2147
- // Handle node additions and removals
2148
- const desiredIds = new Set(def.nodes.map((n) => n.nodeId));
2149
- const currentIds = new Set(this.graphStructure.getNodes().keys());
2150
- // Remove nodes not present
2151
- for (const nodeId of Array.from(currentIds)) {
2152
- if (!desiredIds.has(nodeId)) {
2153
- const node = this.graphStructure.getNode(nodeId);
2154
- // Cancel all active runs and emit cancellation events
2155
- this.executionScheduler.cancelNodeActiveRuns(node, "node-deleted");
2156
- // Cancel node in all run-contexts (marks it as cancelled and clears activeRunContexts)
2157
- this.runContextManager.cancelNodeInRunContexts(nodeId,
2158
- /* includeDownstream */ true, this.graphStructure.getEdges(), this.graphStructure.getNodes());
2159
- // Check for run-context completion (they may finish if pending reaches 0)
2160
- const allRunContexts = this.runContextManager.getAllRunContexts();
2161
- for (const ctx of Array.from(allRunContexts.values())) {
2162
- if (ctx.pending === 0) {
2163
- this.runContextManager.finishRunContext(ctx.id, this.graphStructure.getNodes());
2164
- }
2306
+ {
2307
+ // Delete nodes that are no longer in the definition
2308
+ const afterIds = new Set(def.nodes.map((n) => n.nodeId));
2309
+ const beforeIds = new Set(this.graph.getNodes().keys());
2310
+ for (const nodeId of Array.from(beforeIds)) {
2311
+ if (!afterIds.has(nodeId)) {
2312
+ const node = this.graph.getNode(nodeId);
2313
+ this.nodeExecutor.cancelNodeActiveRuns(node, "node-deleted");
2314
+ this.runContextManager.cancelNodeInRunContexts(nodeId, true);
2315
+ node.runtime.onDeactivated?.();
2316
+ node.runtime.dispose?.();
2317
+ node.lifecycle?.dispose?.({
2318
+ state: node.state,
2319
+ setState: (next) => Object.assign(node.state, next),
2320
+ });
2321
+ this.graph.deleteNode(nodeId);
2322
+ this.edgePropagator.clearArrayBuckets(nodeId);
2165
2323
  }
2166
- // Cleanup node resources
2167
- node.runtime.onDeactivated?.();
2168
- node.runtime.dispose?.();
2169
- node.lifecycle?.dispose?.({
2170
- state: node.state,
2171
- setState: (next) => Object.assign(node.state, next),
2172
- });
2173
- this.graphStructure.deleteNode(nodeId);
2174
- this.valuePropagator.clearArrayBuckets(nodeId);
2175
2324
  }
2176
2325
  }
2177
- // Add or update existing nodes
2178
- for (const n of def.nodes) {
2179
- const existing = this.graphStructure.getNode(n.nodeId);
2180
- if (!existing) {
2181
- // create new runtime node
2182
- const desc = registry.nodes.get(n.typeId);
2183
- if (!desc)
2184
- throw new Error(`Unknown node type: ${n.typeId}`);
2185
- const cat = registry.categories.get(desc.categoryId);
2186
- if (!cat)
2187
- throw new Error(`Unknown category: ${desc.categoryId}`);
2188
- if (cat.validateImpl)
2189
- cat.validateImpl(desc.impl);
2190
- const runtime = cat.createRuntime({
2191
- nodeId: n.nodeId,
2192
- impl: desc.impl,
2193
- });
2194
- const rn = {
2195
- typeId: n.typeId,
2196
- nodeId: n.nodeId,
2197
- lifecycle: desc.lifecycle,
2198
- inputs: {},
2199
- outputs: {},
2200
- state: {},
2201
- runtime,
2202
- params: n.params,
2203
- policy: {
2204
- ...cat.policy,
2205
- ...desc.policy,
2206
- ...n.params?.policy,
2207
- },
2208
- runSeq: 0,
2209
- activeControllers: new Set(),
2210
- controllerRunIds: new Map(),
2211
- queue: [],
2212
- stats: {
2213
- runs: 0,
2214
- active: 0,
2215
- queued: 0,
2216
- progress: 0,
2217
- },
2218
- activeRunContexts: new Set(),
2219
- };
2220
- this.graphStructure.setNode(n.nodeId, rn);
2221
- // Activate new node
2222
- const effectiveInputs = this.executionScheduler.getEffectiveInputs(rn.nodeId);
2223
- const ctrl = new AbortController();
2224
- const ctx = this.executionScheduler.createExecutionContext(rn.nodeId, rn, effectiveInputs, `${rn.nodeId}:init`, ctrl.signal);
2225
- if (rn.lifecycle?.prepare) {
2226
- ctx.log("debug", "prepare-start");
2227
- rn.lifecycle.prepare(rn.params ?? {}, ctx);
2228
- ctx.log("debug", "prepare-done");
2229
- }
2230
- rn.runtime.onActivated?.();
2231
- }
2232
- else {
2233
- // update params/policy
2234
- existing.params = n.params;
2235
- if (!existing.stats) {
2236
- existing.stats = {
2237
- runs: 0,
2238
- active: 0,
2239
- queued: 0,
2240
- progress: 0,
2326
+ {
2327
+ // Add or update nodes that are in the definition
2328
+ for (const n of def.nodes) {
2329
+ const existing = this.graph.getNode(n.nodeId);
2330
+ if (!existing) {
2331
+ const desc = registry.nodes.get(n.typeId);
2332
+ if (!desc)
2333
+ throw new Error(`Unknown node type: ${n.typeId}`);
2334
+ const cat = registry.categories.get(desc.categoryId);
2335
+ if (!cat)
2336
+ throw new Error(`Unknown category: ${desc.categoryId}`);
2337
+ if (cat.validateImpl)
2338
+ cat.validateImpl(desc.impl);
2339
+ const runtime = cat.createRuntime({
2340
+ nodeId: n.nodeId,
2341
+ impl: desc.impl,
2342
+ });
2343
+ const newNode = {
2344
+ typeId: n.typeId,
2345
+ nodeId: n.nodeId,
2346
+ lifecycle: desc.lifecycle,
2347
+ inputs: {},
2348
+ outputs: {},
2349
+ state: {},
2350
+ runtime,
2351
+ params: n.params,
2352
+ policy: {
2353
+ ...cat.policy,
2354
+ ...desc.policy,
2355
+ ...n.params?.policy,
2356
+ },
2357
+ runSeq: 0,
2358
+ activeControllers: new Set(),
2359
+ controllerRunIds: new Map(),
2360
+ queue: [],
2361
+ stats: {
2362
+ runs: 0,
2363
+ active: 0,
2364
+ queued: 0,
2365
+ progress: 0,
2366
+ },
2367
+ activeRunContextIds: new Set(),
2241
2368
  };
2369
+ this.graph.setNode(n.nodeId, newNode);
2370
+ const effectiveInputs = this.nodeExecutor.getEffectiveInputs(newNode.nodeId);
2371
+ const ctrl = new AbortController();
2372
+ const ctx = this.nodeExecutor.createExecutionContext(newNode.nodeId, newNode, effectiveInputs, `${newNode.nodeId}:init`, ctrl.signal);
2373
+ if (newNode.lifecycle?.prepare) {
2374
+ ctx.log("debug", "prepare-start");
2375
+ newNode.lifecycle.prepare(newNode.params ?? {}, ctx);
2376
+ ctx.log("debug", "prepare-done");
2377
+ }
2378
+ newNode.runtime.onActivated?.();
2379
+ }
2380
+ else {
2381
+ existing.params = n.params;
2382
+ if (!existing.stats) {
2383
+ existing.stats = {
2384
+ runs: 0,
2385
+ active: 0,
2386
+ queued: 0,
2387
+ progress: 0,
2388
+ };
2389
+ }
2242
2390
  }
2243
2391
  }
2244
2392
  }
2245
- // Capture previous inbound map before rebuilding edges
2246
- const edges = this.graphStructure.getEdges();
2247
- const prevInbound = new Map();
2248
- for (const e of edges) {
2249
- const set = prevInbound.get(e.target.nodeId) ?? new Set();
2250
- set.add(e.target.handle);
2251
- prevInbound.set(e.target.nodeId, set);
2252
- }
2253
- // Capture previous per-handle target sets before rebuilding edges
2254
- const prevOutTargets = new Map();
2255
- for (const e of edges) {
2256
- const tmap = prevOutTargets.get(e.source.nodeId) ?? new Map();
2257
- const tset = tmap.get(e.source.handle) ?? new Set();
2258
- tset.add(`${e.target.nodeId}.${e.target.handle}`);
2259
- tmap.set(e.source.handle, tset);
2260
- prevOutTargets.set(e.source.nodeId, tmap);
2261
- }
2262
- // Precompute per-node resolved handles for updated graph (include dynamic)
2263
- const resolved = GraphStructure.computeResolvedHandleMap(def, registry, this.environment);
2264
- // Check which handles changed and emit events for those
2265
- const changedHandles = {};
2266
- for (const [nodeId, newHandles] of resolved.map) {
2267
- const oldHandles = this.graphStructure.getResolvedHandles(nodeId);
2268
- if (!oldHandles ||
2269
- JSON.stringify(oldHandles) !== JSON.stringify(newHandles)) {
2270
- changedHandles[nodeId] = newHandles;
2393
+ {
2394
+ const beforeEdges = this.graph.getEdges();
2395
+ const beforeInbound = new Map();
2396
+ for (const e of beforeEdges) {
2397
+ const set = beforeInbound.get(e.target.nodeId) ?? new Set();
2398
+ set.add(e.target.handle);
2399
+ beforeInbound.set(e.target.nodeId, set);
2271
2400
  }
2272
- }
2273
- // Update resolved handles
2274
- for (const [nodeId, handles] of resolved.map) {
2275
- this.graphStructure.setResolvedHandles(nodeId, handles);
2276
- }
2277
- // Rebuild edges mapping with coercions
2278
- const newEdges = GraphStructure.buildEdges(def, registry, this.graphStructure.getResolvedHandlesMap());
2279
- this.graphStructure.setEdges(newEdges);
2280
- // Build new inbound map
2281
- const nextInbound = new Map();
2282
- const updatedEdges = this.graphStructure.getEdges();
2283
- for (const e of updatedEdges) {
2284
- const set = nextInbound.get(e.target.nodeId) ?? new Set();
2285
- set.add(e.target.handle);
2286
- nextInbound.set(e.target.nodeId, set);
2287
- }
2288
- // For inputs that lost inbound connections, clear and schedule recompute
2289
- for (const [nodeId, prevSet] of prevInbound) {
2290
- const currSet = nextInbound.get(nodeId) ?? new Set();
2291
- const node = this.graphStructure.getNode(nodeId);
2292
- if (!node)
2293
- continue;
2294
- let changed = false;
2295
- for (const handle of Array.from(prevSet)) {
2296
- if (!currSet.has(handle)) {
2297
- if (handle in node.inputs) {
2298
- delete node.inputs[handle];
2299
- changed = true;
2401
+ const beforeOutTargets = new Map();
2402
+ for (const e of beforeEdges) {
2403
+ const tmap = beforeOutTargets.get(e.source.nodeId) ??
2404
+ new Map();
2405
+ const tset = tmap.get(e.source.handle) ?? new Set();
2406
+ tset.add(`${e.target.nodeId}.${e.target.handle}`);
2407
+ tmap.set(e.source.handle, tset);
2408
+ beforeOutTargets.set(e.source.nodeId, tmap);
2409
+ }
2410
+ {
2411
+ // Update handles and edges
2412
+ const result = tryHandleResolving(def, registry, this.environment);
2413
+ const changedHandles = {};
2414
+ for (const [nodeId, newHandles] of result.resolved) {
2415
+ const oldHandles = this.graph.getResolvedHandles(nodeId);
2416
+ if (!oldHandles ||
2417
+ JSON.stringify(oldHandles) !== JSON.stringify(newHandles)) {
2418
+ changedHandles[nodeId] = newHandles;
2300
2419
  }
2301
2420
  }
2421
+ for (const [nodeId, handles] of result.resolved) {
2422
+ this.graph.setResolvedHandles(nodeId, handles);
2423
+ }
2424
+ const afterEdges = buildEdges(def, registry, this.graph.getResolvedHandlesMap());
2425
+ this.graph.setEdges(afterEdges);
2426
+ for (const nodeId of result.pending) {
2427
+ this.handleResolver.scheduleRecomputeHandles(nodeId);
2428
+ }
2429
+ if (Object.keys(changedHandles).length > 0) {
2430
+ this.eventEmitter.emit("invalidate", {
2431
+ reason: "graph-updated",
2432
+ resolvedHandles: changedHandles,
2433
+ });
2434
+ }
2302
2435
  }
2303
- if (changed) {
2304
- // Clear buckets for handles that lost inbound (handled by ValuePropagator)
2305
- this.valuePropagator.clearArrayBuckets(nodeId);
2306
- this.executionScheduler.scheduleInputsChangedInternal(nodeId);
2307
- }
2308
- }
2309
- // Re-emit outputs when per-handle target sets change (precise and simple)
2310
- const nextOutTargets = new Map();
2311
- for (const e of updatedEdges) {
2312
- const tmap = nextOutTargets.get(e.source.nodeId) ?? new Map();
2313
- const tset = tmap.get(e.source.handle) ?? new Set();
2314
- tset.add(`${e.target.nodeId}.${e.target.handle}`);
2315
- tmap.set(e.source.handle, tset);
2316
- nextOutTargets.set(e.source.nodeId, tmap);
2317
- }
2318
- const setsEqualStr = (a, b) => {
2319
- if (!a && !b)
2320
- return true;
2321
- if (!a || !b)
2322
- return false;
2323
- if (a.size !== b.size)
2324
- return false;
2325
- for (const v of a)
2326
- if (!b.has(v))
2327
- return false;
2328
- return true;
2329
- };
2330
- const nodesToCheck = new Set([
2331
- ...Array.from(prevOutTargets.keys()),
2332
- ...Array.from(nextOutTargets.keys()),
2333
- ]);
2334
- for (const nodeId of nodesToCheck) {
2335
- const pmap = prevOutTargets.get(nodeId) ?? new Map();
2336
- const nmap = nextOutTargets.get(nodeId) ?? new Map();
2337
- const handles = new Set([
2338
- ...Array.from(pmap.keys()),
2339
- ...Array.from(nmap.keys()),
2340
- ]);
2341
- for (const handle of handles) {
2342
- const pset = pmap.get(handle) ?? new Set();
2343
- const nset = nmap.get(handle) ?? new Set();
2344
- if (!setsEqualStr(pset, nset)) {
2345
- const val = this.getOutput(nodeId, handle);
2346
- if (val !== undefined)
2347
- this.valuePropagator.propagate(nodeId, handle, val);
2348
- else if (this.executionScheduler.allInboundHaveValue(nodeId))
2349
- this.executionScheduler.scheduleInputsChangedInternal(nodeId);
2436
+ {
2437
+ // Update inputs and propagate changes
2438
+ const afterInbound = new Map();
2439
+ const afterEdges = this.graph.getEdges();
2440
+ for (const e of afterEdges) {
2441
+ const set = afterInbound.get(e.target.nodeId) ?? new Set();
2442
+ set.add(e.target.handle);
2443
+ afterInbound.set(e.target.nodeId, set);
2444
+ }
2445
+ // Propagate changes on edges removed
2446
+ for (const [nodeId, beforeSet] of beforeInbound) {
2447
+ const currSet = afterInbound.get(nodeId) ?? new Set();
2448
+ const node = this.graph.getNode(nodeId);
2449
+ if (!node)
2450
+ continue;
2451
+ let changed = false;
2452
+ for (const handle of Array.from(beforeSet)) {
2453
+ if (!currSet.has(handle)) {
2454
+ if (handle in node.inputs) {
2455
+ delete node.inputs[handle];
2456
+ changed = true;
2457
+ }
2458
+ }
2459
+ }
2460
+ if (changed) {
2461
+ this.edgePropagator.clearArrayBuckets(nodeId);
2462
+ if (this.runMode === "auto" &&
2463
+ this.graph.allInboundHaveValue(nodeId)) {
2464
+ this.execute(nodeId);
2465
+ }
2466
+ }
2467
+ }
2468
+ // Propagate changes on edges added
2469
+ const afterOutTargets = new Map();
2470
+ for (const e of afterEdges) {
2471
+ const targetMap = afterOutTargets.get(e.source.nodeId) ??
2472
+ new Map();
2473
+ const targetSet = targetMap.get(e.source.handle) ?? new Set();
2474
+ targetSet.add(`${e.target.nodeId}.${e.target.handle}`);
2475
+ targetMap.set(e.source.handle, targetSet);
2476
+ afterOutTargets.set(e.source.nodeId, targetMap);
2477
+ }
2478
+ const setsEqual = (a, b) => {
2479
+ if (!a && !b)
2480
+ return true;
2481
+ if (!a || !b)
2482
+ return false;
2483
+ if (a.size !== b.size)
2484
+ return false;
2485
+ for (const v of a)
2486
+ if (!b.has(v))
2487
+ return false;
2488
+ return true;
2489
+ };
2490
+ const nodesToCheck = new Set([
2491
+ ...Array.from(beforeOutTargets.keys()),
2492
+ ...Array.from(afterOutTargets.keys()),
2493
+ ]);
2494
+ for (const nodeId of nodesToCheck) {
2495
+ const beforeMap = beforeOutTargets.get(nodeId) ?? new Map();
2496
+ const afterMap = afterOutTargets.get(nodeId) ?? new Map();
2497
+ const handles = new Set([
2498
+ ...Array.from(beforeMap.keys()),
2499
+ ...Array.from(afterMap.keys()),
2500
+ ]);
2501
+ for (const handle of handles) {
2502
+ const beforeTargetSet = beforeMap.get(handle) ?? new Set();
2503
+ const afterTargetSet = afterMap.get(handle) ?? new Set();
2504
+ if (!setsEqual(beforeTargetSet, afterTargetSet)) {
2505
+ const val = this.getOutput(nodeId, handle);
2506
+ if (val !== undefined)
2507
+ this.propagate(nodeId, handle, val);
2508
+ }
2509
+ }
2350
2510
  }
2351
2511
  }
2352
2512
  }
2353
- // Prune array bucket contributions for edges that no longer exist
2354
- // This is handled by ValuePropagator - array buckets are managed there
2355
- // The buckets will be cleaned up automatically when edges are removed
2356
- // Schedule async recompute for nodes that indicated Promise-based resolveHandles in this update
2357
- // Emit event for changed handles (if any)
2358
- if (Object.keys(changedHandles).length > 0) {
2359
- this.eventEmitter.emit("invalidate", {
2360
- reason: "graph-updated",
2361
- resolvedHandles: changedHandles,
2513
+ }
2514
+ dispose() {
2515
+ this.runContextManager.resolveAll();
2516
+ for (const node of this.graph.getNodes().values()) {
2517
+ node.runtime.onDeactivated?.();
2518
+ node.runtime.dispose?.();
2519
+ node.lifecycle?.dispose?.({
2520
+ state: node.state,
2521
+ setState: (next) => Object.assign(node.state, next),
2362
2522
  });
2363
2523
  }
2364
- for (const nodeId of resolved.pending) {
2365
- this.handleResolver.scheduleRecomputeHandles(nodeId);
2366
- }
2524
+ this.graph.clear();
2525
+ }
2526
+ execute(nodeId, runContextIds) {
2527
+ this.nodeExecutor.execute(nodeId, runContextIds);
2528
+ }
2529
+ propagate(srcNodeId, srcHandle, value, runContextIds) {
2530
+ this.edgePropagator.propagate(srcNodeId, srcHandle, value, runContextIds);
2531
+ }
2532
+ invalidateDownstream(nodeId) {
2533
+ this.edgePropagator.invalidateDownstream(nodeId);
2367
2534
  }
2368
2535
  }
2369
2536
 
@@ -2585,21 +2752,25 @@ class GraphBuilder {
2585
2752
  }
2586
2753
  }
2587
2754
 
2588
- class AbstractEngine {
2589
- constructor(graphRuntime) {
2755
+ /**
2756
+ * Unified Engine implementation that handles both manual and auto run modes.
2757
+ * - Manual mode: Nodes execute only when explicitly called via computeNode/runFromHere (unless paused)
2758
+ * - Auto mode: Nodes automatically execute when inputs change (unless paused)
2759
+ */
2760
+ class LocalEngine {
2761
+ constructor(graphRuntime, runMode) {
2590
2762
  this.graphRuntime = graphRuntime;
2763
+ this.setRunMode(runMode ?? "manual");
2591
2764
  }
2592
2765
  setInputs(nodeId, inputs, options) {
2593
2766
  if (options?.dry) {
2594
- const wasPaused = this.graphRuntime.isPaused();
2595
- if (!wasPaused)
2596
- this.graphRuntime.pause();
2767
+ // Use requestPause to temporarily pause without affecting base run mode
2768
+ const releasePause = this.graphRuntime.requestPause();
2597
2769
  try {
2598
2770
  this.graphRuntime.setInputs(nodeId, inputs);
2599
2771
  }
2600
2772
  finally {
2601
- if (!wasPaused)
2602
- this.graphRuntime.resume();
2773
+ releasePause();
2603
2774
  }
2604
2775
  }
2605
2776
  else {
@@ -2608,15 +2779,13 @@ class AbstractEngine {
2608
2779
  }
2609
2780
  triggerExternal(nodeId, event, options) {
2610
2781
  if (options?.dry) {
2611
- const wasPaused = this.graphRuntime.isPaused();
2612
- if (!wasPaused)
2613
- this.graphRuntime.pause();
2782
+ // Use requestPause to temporarily pause without affecting base run mode
2783
+ const releasePause = this.graphRuntime.requestPause();
2614
2784
  try {
2615
2785
  this.graphRuntime.triggerExternal(nodeId, event);
2616
2786
  }
2617
2787
  finally {
2618
- if (!wasPaused)
2619
- this.graphRuntime.resume();
2788
+ releasePause();
2620
2789
  }
2621
2790
  }
2622
2791
  else {
@@ -2641,19 +2810,6 @@ class AbstractEngine {
2641
2810
  dispose() {
2642
2811
  // this.graphRuntime.dispose();
2643
2812
  }
2644
- }
2645
-
2646
- /**
2647
- * Unified Engine implementation that handles both manual and auto run modes.
2648
- * - Manual mode: Runtime is paused, nodes execute only when explicitly called via computeNode/runFromHere
2649
- * - Auto mode: Runtime is resumed, nodes automatically execute when inputs change
2650
- */
2651
- class UnifiedEngine extends AbstractEngine {
2652
- constructor(graphRuntime, runMode) {
2653
- super(graphRuntime);
2654
- this.runMode = "manual";
2655
- this.setRunMode(runMode ?? "manual");
2656
- }
2657
2813
  launch(invalidate, runMode) {
2658
2814
  if (runMode)
2659
2815
  this.setRunMode(runMode);
@@ -2680,20 +2836,8 @@ class UnifiedEngine extends AbstractEngine {
2680
2836
  async runFromHere(nodeId) {
2681
2837
  await this.graphRuntime.runFromHereContext(nodeId);
2682
2838
  }
2683
- getRunMode() {
2684
- return this.runMode;
2685
- }
2686
2839
  setRunMode(runMode) {
2687
- if (this.runMode === runMode)
2688
- return;
2689
- this.runMode = runMode;
2690
- // Update runtime pause/resume state based on new mode
2691
- if (runMode === "manual") {
2692
- this.graphRuntime.pause();
2693
- }
2694
- else {
2695
- this.graphRuntime.resume();
2696
- }
2840
+ this.graphRuntime.setRunMode(runMode);
2697
2841
  }
2698
2842
  }
2699
2843
 
@@ -4240,6 +4384,58 @@ function setValueAtPath(obj, pathSegments, newValue) {
4240
4384
  }
4241
4385
  return true;
4242
4386
  }
4387
+ /**
4388
+ * Sets a value at a path, creating intermediate objects as needed.
4389
+ * Mutates the root object in place.
4390
+ * @param root - The root object to modify (must be an object, will be initialized if needed)
4391
+ * @param pathSegments - The path segments to traverse
4392
+ * @param value - The value to set, or null to delete the path
4393
+ * @throws Error if path cannot be created (e.g., array indices not supported, invalid parent types)
4394
+ */
4395
+ function setValueAtPathWithCreation(root, pathSegments, value) {
4396
+ if (value === null) {
4397
+ const result = getValueAtPath(root, pathSegments);
4398
+ if (result && result.parent !== null && !Array.isArray(result.parent)) {
4399
+ delete result.parent[result.key];
4400
+ }
4401
+ return;
4402
+ }
4403
+ if (!root || typeof root !== "object" || Array.isArray(root)) {
4404
+ throw new Error("Root must be an object");
4405
+ }
4406
+ let current = root;
4407
+ for (let i = 0; i < pathSegments.length - 1; i++) {
4408
+ const segment = pathSegments[i];
4409
+ if (typeof segment === "string") {
4410
+ if (!current ||
4411
+ typeof current !== "object" ||
4412
+ Array.isArray(current) ||
4413
+ !(segment in current) ||
4414
+ typeof current[segment] !== "object" ||
4415
+ current[segment] === null ||
4416
+ Array.isArray(current[segment])) {
4417
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
4418
+ throw new Error(`Cannot create path: parent at segment ${i} is not an object`);
4419
+ }
4420
+ current[segment] = {};
4421
+ }
4422
+ current = current[segment];
4423
+ }
4424
+ else {
4425
+ throw new Error("Array indices not supported in extData paths");
4426
+ }
4427
+ }
4428
+ const lastSegment = pathSegments[pathSegments.length - 1];
4429
+ if (typeof lastSegment === "string") {
4430
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
4431
+ throw new Error(`Cannot set value: parent at final segment is not an object`);
4432
+ }
4433
+ current[lastSegment] = value;
4434
+ }
4435
+ else {
4436
+ throw new Error("Array indices not supported in extData paths");
4437
+ }
4438
+ }
4243
4439
  function findMatchingPaths(obj, pathSegments, currentPath = []) {
4244
4440
  if (pathSegments.length === 0) {
4245
4441
  return [{ path: currentPath, value: obj }];
@@ -4781,8 +4977,8 @@ exports.CompositeCategory = CompositeCategory;
4781
4977
  exports.ComputeCategory = ComputeCategory;
4782
4978
  exports.GraphBuilder = GraphBuilder;
4783
4979
  exports.GraphRuntime = GraphRuntime;
4980
+ exports.LocalEngine = LocalEngine;
4784
4981
  exports.Registry = Registry;
4785
- exports.UnifiedEngine = UnifiedEngine;
4786
4982
  exports.buildValueConverter = buildValueConverter;
4787
4983
  exports.computeGraphCenter = computeGraphCenter;
4788
4984
  exports.createAsyncGraphDef = createAsyncGraphDef;
@@ -4811,5 +5007,6 @@ exports.parseJsonPath = parseJsonPath;
4811
5007
  exports.registerDelayNode = registerDelayNode;
4812
5008
  exports.registerProgressNodes = registerProgressNodes;
4813
5009
  exports.setValueAtPath = setValueAtPath;
5010
+ exports.setValueAtPathWithCreation = setValueAtPathWithCreation;
4814
5011
  exports.typed = typed;
4815
5012
  //# sourceMappingURL=index.cjs.map