@bian-womp/spark-graph 0.2.94 → 0.3.1

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 (117) hide show
  1. package/lib/cjs/index.cjs +1462 -994
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/order.d.ts +7 -0
  4. package/lib/cjs/src/core/order.d.ts.map +1 -0
  5. package/lib/cjs/src/examples/async.d.ts.map +1 -1
  6. package/lib/cjs/src/examples/progress.d.ts.map +1 -1
  7. package/lib/cjs/src/examples/runMode.d.ts +2 -0
  8. package/lib/cjs/src/examples/runMode.d.ts.map +1 -0
  9. package/lib/cjs/src/examples/shared.d.ts.map +1 -1
  10. package/lib/cjs/src/examples/simple.d.ts.map +1 -1
  11. package/lib/cjs/src/examples/snapshot.d.ts.map +1 -1
  12. package/lib/cjs/src/examples/validation.d.ts.map +1 -1
  13. package/lib/cjs/src/index.d.ts +4 -7
  14. package/lib/cjs/src/index.d.ts.map +1 -1
  15. package/lib/cjs/src/misc/base.d.ts +41 -0
  16. package/lib/cjs/src/misc/base.d.ts.map +1 -1
  17. package/lib/cjs/src/runtime/AbstractEngine.d.ts +18 -4
  18. package/lib/cjs/src/runtime/AbstractEngine.d.ts.map +1 -1
  19. package/lib/cjs/src/runtime/Engine.d.ts +17 -3
  20. package/lib/cjs/src/runtime/Engine.d.ts.map +1 -1
  21. package/lib/cjs/src/runtime/GraphLifecycleApi.d.ts +37 -0
  22. package/lib/cjs/src/runtime/GraphLifecycleApi.d.ts.map +1 -0
  23. package/lib/cjs/src/runtime/GraphRuntime.d.ts +29 -29
  24. package/lib/cjs/src/runtime/GraphRuntime.d.ts.map +1 -1
  25. package/lib/cjs/src/runtime/UnifiedEngine.d.ts +32 -0
  26. package/lib/cjs/src/runtime/UnifiedEngine.d.ts.map +1 -0
  27. package/lib/cjs/src/runtime/components/EventEmitter.d.ts +12 -0
  28. package/lib/cjs/src/runtime/components/EventEmitter.d.ts.map +1 -0
  29. package/lib/cjs/src/runtime/components/ExecutionScheduler.d.ts +56 -0
  30. package/lib/cjs/src/runtime/components/ExecutionScheduler.d.ts.map +1 -0
  31. package/lib/cjs/src/runtime/components/GraphStructure.d.ts +36 -0
  32. package/lib/cjs/src/runtime/components/GraphStructure.d.ts.map +1 -0
  33. package/lib/cjs/src/runtime/components/HandleResolver.d.ts +27 -0
  34. package/lib/cjs/src/runtime/components/HandleResolver.d.ts.map +1 -0
  35. package/lib/cjs/src/runtime/components/RunContextManager.d.ts +55 -0
  36. package/lib/cjs/src/runtime/components/RunContextManager.d.ts.map +1 -0
  37. package/lib/cjs/src/runtime/components/ValuePropagator.d.ts +46 -0
  38. package/lib/cjs/src/runtime/components/ValuePropagator.d.ts.map +1 -0
  39. package/lib/cjs/src/runtime/components/interfaces.d.ts +34 -0
  40. package/lib/cjs/src/runtime/components/interfaces.d.ts.map +1 -0
  41. package/lib/cjs/src/runtime/components/types.d.ts +43 -0
  42. package/lib/cjs/src/runtime/components/types.d.ts.map +1 -0
  43. package/lib/cjs/src/runtime/utils.d.ts +16 -0
  44. package/lib/cjs/src/runtime/utils.d.ts.map +1 -0
  45. package/lib/esm/index.js +1459 -989
  46. package/lib/esm/index.js.map +1 -1
  47. package/lib/esm/src/core/order.d.ts +7 -0
  48. package/lib/esm/src/core/order.d.ts.map +1 -0
  49. package/lib/esm/src/examples/async.d.ts.map +1 -1
  50. package/lib/esm/src/examples/progress.d.ts.map +1 -1
  51. package/lib/esm/src/examples/runMode.d.ts +2 -0
  52. package/lib/esm/src/examples/runMode.d.ts.map +1 -0
  53. package/lib/esm/src/examples/shared.d.ts.map +1 -1
  54. package/lib/esm/src/examples/simple.d.ts.map +1 -1
  55. package/lib/esm/src/examples/snapshot.d.ts.map +1 -1
  56. package/lib/esm/src/examples/validation.d.ts.map +1 -1
  57. package/lib/esm/src/index.d.ts +4 -7
  58. package/lib/esm/src/index.d.ts.map +1 -1
  59. package/lib/esm/src/misc/base.d.ts +41 -0
  60. package/lib/esm/src/misc/base.d.ts.map +1 -1
  61. package/lib/esm/src/runtime/AbstractEngine.d.ts +18 -4
  62. package/lib/esm/src/runtime/AbstractEngine.d.ts.map +1 -1
  63. package/lib/esm/src/runtime/Engine.d.ts +17 -3
  64. package/lib/esm/src/runtime/Engine.d.ts.map +1 -1
  65. package/lib/esm/src/runtime/GraphLifecycleApi.d.ts +37 -0
  66. package/lib/esm/src/runtime/GraphLifecycleApi.d.ts.map +1 -0
  67. package/lib/esm/src/runtime/GraphRuntime.d.ts +29 -29
  68. package/lib/esm/src/runtime/GraphRuntime.d.ts.map +1 -1
  69. package/lib/esm/src/runtime/UnifiedEngine.d.ts +32 -0
  70. package/lib/esm/src/runtime/UnifiedEngine.d.ts.map +1 -0
  71. package/lib/esm/src/runtime/components/EventEmitter.d.ts +12 -0
  72. package/lib/esm/src/runtime/components/EventEmitter.d.ts.map +1 -0
  73. package/lib/esm/src/runtime/components/ExecutionScheduler.d.ts +56 -0
  74. package/lib/esm/src/runtime/components/ExecutionScheduler.d.ts.map +1 -0
  75. package/lib/esm/src/runtime/components/GraphStructure.d.ts +36 -0
  76. package/lib/esm/src/runtime/components/GraphStructure.d.ts.map +1 -0
  77. package/lib/esm/src/runtime/components/HandleResolver.d.ts +27 -0
  78. package/lib/esm/src/runtime/components/HandleResolver.d.ts.map +1 -0
  79. package/lib/esm/src/runtime/components/RunContextManager.d.ts +55 -0
  80. package/lib/esm/src/runtime/components/RunContextManager.d.ts.map +1 -0
  81. package/lib/esm/src/runtime/components/ValuePropagator.d.ts +46 -0
  82. package/lib/esm/src/runtime/components/ValuePropagator.d.ts.map +1 -0
  83. package/lib/esm/src/runtime/components/interfaces.d.ts +34 -0
  84. package/lib/esm/src/runtime/components/interfaces.d.ts.map +1 -0
  85. package/lib/esm/src/runtime/components/types.d.ts +43 -0
  86. package/lib/esm/src/runtime/components/types.d.ts.map +1 -0
  87. package/lib/esm/src/runtime/utils.d.ts +16 -0
  88. package/lib/esm/src/runtime/utils.d.ts.map +1 -0
  89. package/package.json +2 -2
  90. package/lib/cjs/src/examples/engine.d.ts +0 -6
  91. package/lib/cjs/src/examples/engine.d.ts.map +0 -1
  92. package/lib/cjs/src/runtime/BatchedEngine.d.ts +0 -17
  93. package/lib/cjs/src/runtime/BatchedEngine.d.ts.map +0 -1
  94. package/lib/cjs/src/runtime/EngineFactory.d.ts +0 -10
  95. package/lib/cjs/src/runtime/EngineFactory.d.ts.map +0 -1
  96. package/lib/cjs/src/runtime/HybridEngine.d.ts +0 -21
  97. package/lib/cjs/src/runtime/HybridEngine.d.ts.map +0 -1
  98. package/lib/cjs/src/runtime/PullEngine.d.ts +0 -8
  99. package/lib/cjs/src/runtime/PullEngine.d.ts.map +0 -1
  100. package/lib/cjs/src/runtime/PushEngine.d.ts +0 -7
  101. package/lib/cjs/src/runtime/PushEngine.d.ts.map +0 -1
  102. package/lib/cjs/src/runtime/StepEngine.d.ts +0 -11
  103. package/lib/cjs/src/runtime/StepEngine.d.ts.map +0 -1
  104. package/lib/esm/src/examples/engine.d.ts +0 -6
  105. package/lib/esm/src/examples/engine.d.ts.map +0 -1
  106. package/lib/esm/src/runtime/BatchedEngine.d.ts +0 -17
  107. package/lib/esm/src/runtime/BatchedEngine.d.ts.map +0 -1
  108. package/lib/esm/src/runtime/EngineFactory.d.ts +0 -10
  109. package/lib/esm/src/runtime/EngineFactory.d.ts.map +0 -1
  110. package/lib/esm/src/runtime/HybridEngine.d.ts +0 -21
  111. package/lib/esm/src/runtime/HybridEngine.d.ts.map +0 -1
  112. package/lib/esm/src/runtime/PullEngine.d.ts +0 -8
  113. package/lib/esm/src/runtime/PullEngine.d.ts.map +0 -1
  114. package/lib/esm/src/runtime/PushEngine.d.ts +0 -7
  115. package/lib/esm/src/runtime/PushEngine.d.ts.map +0 -1
  116. package/lib/esm/src/runtime/StepEngine.d.ts +0 -11
  117. package/lib/esm/src/runtime/StepEngine.d.ts.map +0 -1
package/lib/cjs/index.cjs CHANGED
@@ -404,186 +404,191 @@ class Registry {
404
404
  }
405
405
  Registry.idCounter = 0;
406
406
 
407
- const LOG_LEVEL_VALUES = {
408
- debug: 0,
409
- info: 1,
410
- warn: 2,
411
- error: 3,
412
- silent: 4,
413
- };
414
-
415
- // Helper: typed promise detection and unwrapping for T | Promise<T>
407
+ /**
408
+ * Shared utility functions for runtime components
409
+ */
410
+ /**
411
+ * Type guard to check if a value is a Promise
412
+ */
416
413
  function isPromise(value) {
417
414
  return !!value && typeof value.then === "function";
418
415
  }
416
+ /**
417
+ * Unwrap a value that might be a Promise
418
+ */
419
419
  async function unwrapMaybePromise(value) {
420
420
  return isPromise(value) ? await value : value;
421
421
  }
422
- class GraphRuntime {
423
- constructor() {
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
443
+ */
444
+ class GraphStructure {
445
+ constructor(registry) {
424
446
  this.nodes = new Map();
425
447
  this.edges = [];
426
- // Current resolved handles per node (registry statics merged with per-node overrides)
427
448
  this.resolvedByNode = new Map();
428
- this.listeners = new Map();
429
- this.environment = {};
430
- // Token to guard async resolveHandles recomputes per node
431
- this.recomputeTokenByNode = new Map();
432
- this.paused = false;
433
- // For array-typed target inputs, keep per-edge contributions so successive runs
434
- // from the same source replace their slice instead of accumulating forever.
435
- // Structure: nodeId -> handle -> edgeId -> values[]
436
- this.arrayInputBuckets = new Map();
449
+ this.registry = registry;
437
450
  }
438
- // Shallow/deep-ish equality to avoid unnecessary runs on identical values
439
- valuesEqual(a, b) {
440
- if (a === b)
441
- return true;
442
- if (typeof a !== typeof b)
443
- return false;
444
- if (a && b && typeof a === "object") {
445
- try {
446
- return JSON.stringify(a) === JSON.stringify(b);
447
- }
448
- catch {
449
- return false;
450
- }
451
- }
452
- return false;
451
+ // Node accessors
452
+ getNode(nodeId) {
453
+ return this.nodes.get(nodeId);
453
454
  }
454
- static create(def, registry, opts) {
455
- const gr = new GraphRuntime();
456
- gr.registry = registry;
457
- gr.environment = opts?.environment ?? {};
458
- // Precompute per-node resolved handles (use def-provided overrides; do not compute dynamically here)
459
- const initial = GraphRuntime.computeResolvedHandleMap(def, registry, gr.environment);
460
- gr.resolvedByNode = initial.map;
461
- // Instantiate nodes
462
- for (const n of def.nodes) {
463
- const desc = registry.nodes.get(n.typeId);
464
- if (!desc)
465
- throw new Error(`Unknown node type: ${n.typeId}`);
466
- const cat = registry.categories.get(desc.categoryId);
467
- if (!cat)
468
- throw new Error(`Unknown category: ${desc.categoryId}`);
469
- if (cat.validateImpl)
470
- cat.validateImpl(desc.impl);
471
- const runtime = cat.createRuntime({
472
- nodeId: n.nodeId,
473
- impl: desc.impl,
474
- });
475
- const rn = {
476
- typeId: n.typeId,
477
- nodeId: n.nodeId,
478
- lifecycle: desc.lifecycle,
479
- inputs: {},
480
- outputs: {},
481
- state: {},
482
- runtime,
483
- params: n.params,
484
- policy: {
485
- ...cat.policy,
486
- ...desc.policy,
487
- ...n.params?.policy,
488
- },
489
- logLevel: desc.logLevel,
490
- runSeq: 0,
491
- activeControllers: new Set(),
492
- controllerRunIds: new Map(),
493
- queue: [],
494
- stats: {
495
- runs: 0,
496
- active: 0,
497
- queued: 0,
498
- progress: 0,
499
- },
500
- };
501
- gr.nodes.set(n.nodeId, rn);
502
- }
503
- // Instantiate edges
504
- gr.edges = GraphRuntime.buildEdges(def, registry, gr.resolvedByNode);
505
- // Schedule async recompute only for nodes that indicated Promise-based resolveHandles
506
- for (const nodeId of initial.pending)
507
- gr.scheduleRecomputeHandles(nodeId);
508
- return gr;
455
+ getNodes() {
456
+ return this.nodes;
509
457
  }
510
- on(event, handler) {
511
- if (!this.listeners.has(event))
512
- this.listeners.set(event, new Set());
513
- const set = this.listeners.get(event);
514
- set.add(handler);
515
- return () => set.delete(handler);
458
+ setNode(nodeId, node) {
459
+ this.nodes.set(nodeId, node);
516
460
  }
517
- emit(event, payload) {
518
- const set = this.listeners.get(event);
519
- if (set)
520
- for (const h of Array.from(set))
521
- h(payload);
461
+ deleteNode(nodeId) {
462
+ this.nodes.delete(nodeId);
522
463
  }
523
- setInputs(nodeId, inputs) {
524
- const node = this.nodes.get(nodeId);
525
- if (!node)
526
- throw new Error(`Node not found: ${nodeId}`);
527
- let anyChanged = false;
528
- for (const [handle, value] of Object.entries(inputs)) {
529
- const hasInbound = this.edges.some((e) => e.target.nodeId === nodeId && e.target.handle === handle);
530
- if (hasInbound)
464
+ hasNode(nodeId) {
465
+ return this.nodes.has(nodeId);
466
+ }
467
+ // Edge accessors
468
+ getEdges() {
469
+ return this.edges;
470
+ }
471
+ setEdges(edges) {
472
+ this.edges = edges;
473
+ }
474
+ // Registry accessor
475
+ getRegistry() {
476
+ return this.registry;
477
+ }
478
+ setRegistry(registry) {
479
+ this.registry = registry;
480
+ }
481
+ // Resolved handles accessors
482
+ getResolvedHandles(nodeId) {
483
+ return this.resolvedByNode.get(nodeId);
484
+ }
485
+ setResolvedHandles(nodeId, handles) {
486
+ this.resolvedByNode.set(nodeId, handles);
487
+ }
488
+ getResolvedHandlesMap() {
489
+ return this.resolvedByNode;
490
+ }
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)
531
498
  continue;
532
- // Validate input value against declared type
533
- if (value !== undefined && this.registry) {
534
- const desc = this.registry.nodes.get(node.typeId);
535
- const resolved = this.resolvedByNode.get(nodeId);
536
- // Get typeId from resolved handles first, then registry statics
537
- const typeId = resolved
538
- ? getInputTypeId(resolved.inputs, handle)
539
- : desc
540
- ? getInputTypeId(desc.inputs, handle)
541
- : undefined;
542
- if (typeId) {
543
- const typeDesc = this.registry.types.get(typeId);
544
- if (typeDesc?.validate && !typeDesc.validate(value)) {
545
- // Emit error event for invalid input value and reject it
546
- const errorMessage = `Invalid value for input ${nodeId}.${handle} (type ${typeId}): ${JSON.stringify(value)}`;
547
- this.emit("error", {
548
- kind: "input-validation",
549
- nodeId,
550
- handle,
551
- typeId,
552
- value,
553
- message: errorMessage,
554
- });
555
- // Skip storing invalid value
556
- 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 || {};
557
519
  }
558
520
  }
559
521
  }
560
- const prev = node.inputs[handle];
561
- const same = this.valuesEqual(prev, value);
562
- if (!same) {
563
- if (value === undefined) {
564
- delete node.inputs[handle];
565
- }
566
- else {
567
- node.inputs[handle] = value;
568
- }
569
- // Emit value event for input updates
570
- this.emit("value", { nodeId, handle, value, io: "input" });
571
- anyChanged = true;
522
+ catch {
523
+ // ignore dynamic resolution errors at this stage
572
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 });
573
542
  }
574
- if (!this.paused) {
575
- // Only schedule if all inbound inputs are present (or there are none)
576
- if (anyChanged && this.allInboundHaveValue(nodeId))
577
- this.scheduleInputsChangedInternal(nodeId);
578
- // Recompute dynamic handles for this node when its direct inputs change
579
- if (anyChanged)
580
- this.scheduleRecomputeHandles(nodeId);
581
- }
543
+ return { map: out, pending };
582
544
  }
583
- getOutput(nodeId, output) {
584
- const node = this.nodes.get(nodeId);
585
- return node?.outputs[output];
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
+ });
586
584
  }
585
+ // Clear all data
586
+ clear() {
587
+ this.nodes.clear();
588
+ this.edges = [];
589
+ this.resolvedByNode.clear();
590
+ }
591
+ // Static helper: build edge converters for type coercion
587
592
  static buildEdgeConverters(srcDeclared, dstDeclared, registry, edgeLabel) {
588
593
  if (!dstDeclared || !srcDeclared) {
589
594
  return {};
@@ -647,27 +652,652 @@ class GraphRuntime {
647
652
  },
648
653
  };
649
654
  }
650
- createExecutionContext(nodeId, node, inputs, runId, abortSignal, options) {
651
- const emitHandler = options?.emitHandler ??
652
- ((handle, value) => this.propagate(nodeId, handle, value));
653
- const reportProgress = options?.reportProgress ??
654
- ((p) => {
655
- node.stats.progress = Math.max(0, Math.min(1, Number(p) || 0));
656
- });
657
- // Create log function that respects node's logLevel
658
- const log = (level, message, context) => {
659
- const nodeLogLevel = node.logLevel ?? "info"; // Default to "info" if not set
660
- const nodeLogValue = LOG_LEVEL_VALUES[nodeLogLevel] ?? 1;
661
- const requestedValue = LOG_LEVEL_VALUES[level] ?? 1;
662
- // Only log if requested level >= node's logLevel
663
- if (requestedValue >= nodeLogValue && nodeLogLevel !== "silent") {
664
- const contextStr = context
665
- ? ` ${Object.entries(context)
655
+ }
656
+
657
+ /**
658
+ * Event emitter component for GraphRuntime
659
+ * Handles all event listener management and emission
660
+ */
661
+ class EventEmitter {
662
+ constructor() {
663
+ this.listeners = new Map();
664
+ }
665
+ on(event, handler) {
666
+ if (!this.listeners.has(event))
667
+ this.listeners.set(event, new Set());
668
+ const set = this.listeners.get(event);
669
+ set.add(handler);
670
+ return () => set.delete(handler);
671
+ }
672
+ emit(event, payload) {
673
+ const set = this.listeners.get(event);
674
+ if (!set)
675
+ return;
676
+ for (const handler of Array.from(set)) {
677
+ try {
678
+ handler(payload);
679
+ }
680
+ catch (err) {
681
+ console.error(`Error in ${event} handler:`, err);
682
+ }
683
+ }
684
+ }
685
+ clear() {
686
+ this.listeners.clear();
687
+ }
688
+ }
689
+
690
+ /**
691
+ * RunContextManager component - manages run-context lifecycle
692
+ */
693
+ class RunContextManager {
694
+ constructor() {
695
+ this.runContexts = new Map();
696
+ this.runContextCounter = 0;
697
+ }
698
+ /**
699
+ * Create a new run-context for runFromHere
700
+ */
701
+ createRunContext(startNodeId, options) {
702
+ const id = `rc-${++this.runContextCounter}`;
703
+ const ctx = {
704
+ id,
705
+ startNodes: new Set([startNodeId]),
706
+ cancelledNodes: new Set(),
707
+ pending: 0,
708
+ skipPropagateValues: options?.skipPropagateValues ?? false,
709
+ propagate: options?.propagate ?? true,
710
+ };
711
+ this.runContexts.set(id, ctx);
712
+ return ctx;
713
+ }
714
+ /**
715
+ * Get a run-context by ID
716
+ */
717
+ getRunContext(id) {
718
+ return this.runContexts.get(id);
719
+ }
720
+ /**
721
+ * Get all run-contexts
722
+ */
723
+ getAllRunContexts() {
724
+ return this.runContexts;
725
+ }
726
+ /**
727
+ * Check if there are any active run-contexts
728
+ */
729
+ hasActiveRunContexts() {
730
+ return this.runContexts.size > 0;
731
+ }
732
+ /**
733
+ * Finish and remove a run-context when its pending count reaches zero
734
+ */
735
+ finishRunContext(id, nodes) {
736
+ const ctx = this.runContexts.get(id);
737
+ if (!ctx)
738
+ return;
739
+ if (ctx.pending > 0)
740
+ return; // Still has pending work
741
+ // Clean up activeRunContexts from all nodes
742
+ for (const node of nodes.values()) {
743
+ node.activeRunContexts.delete(id);
744
+ }
745
+ this.runContexts.delete(id);
746
+ if (ctx.resolve)
747
+ ctx.resolve();
748
+ }
749
+ /**
750
+ * Cancel a node for all run-contexts (called from UI or update())
751
+ * @param nodeId - The node to cancel
752
+ * @param includeDownstream - Whether to also cancel downstream nodes
753
+ * @param edges - All edges in the graph (for downstream traversal)
754
+ * @param nodes - All nodes in the graph (for clearing activeRunContexts)
755
+ */
756
+ cancelNodeInRunContexts(nodeId, includeDownstream, edges, nodes) {
757
+ const toCancel = new Set([nodeId]);
758
+ if (includeDownstream) {
759
+ // Collect all downstream nodes
760
+ const queue = [nodeId];
761
+ const visited = new Set();
762
+ for (let i = 0; i < queue.length; i++) {
763
+ const cur = queue[i];
764
+ if (visited.has(cur))
765
+ continue;
766
+ visited.add(cur);
767
+ for (const e of edges) {
768
+ if (e.source.nodeId === cur) {
769
+ const targetId = e.target.nodeId;
770
+ if (!visited.has(targetId)) {
771
+ toCancel.add(targetId);
772
+ queue.push(targetId);
773
+ }
774
+ }
775
+ }
776
+ }
777
+ }
778
+ // Mark nodes as cancelled in all run-contexts
779
+ for (const ctx of this.runContexts.values()) {
780
+ for (const id of toCancel) {
781
+ ctx.cancelledNodes.add(id);
782
+ }
783
+ }
784
+ // Clear activeRunContexts for cancelled nodes
785
+ for (const id of toCancel) {
786
+ const node = nodes.get(id);
787
+ if (node)
788
+ node.activeRunContexts.clear();
789
+ }
790
+ }
791
+ /**
792
+ * Resolve all pending run-context promises (for cleanup)
793
+ */
794
+ resolveAll() {
795
+ for (const ctx of this.runContexts.values()) {
796
+ if (ctx.resolve)
797
+ ctx.resolve();
798
+ }
799
+ }
800
+ /**
801
+ * Clear all run-contexts
802
+ */
803
+ clear() {
804
+ this.runContexts.clear();
805
+ }
806
+ }
807
+
808
+ const LOG_LEVEL_VALUES = {
809
+ debug: 0,
810
+ info: 1,
811
+ warn: 2,
812
+ error: 3,
813
+ silent: 4,
814
+ };
815
+
816
+ /**
817
+ * HandleResolver component - manages dynamic handle resolution
818
+ */
819
+ class HandleResolver {
820
+ constructor(graphStructure, eventEmitter, runtimeCoordinator, registry, environment) {
821
+ this.recomputeTokenByNode = new Map();
822
+ this.environment = {};
823
+ this.graphStructure = graphStructure;
824
+ this.eventEmitter = eventEmitter;
825
+ this.runtimeCoordinator = runtimeCoordinator;
826
+ this.registry = registry;
827
+ this.environment = environment ?? {};
828
+ }
829
+ setRegistry(registry) {
830
+ this.registry = registry;
831
+ }
832
+ setEnvironment(environment) {
833
+ this.environment = environment;
834
+ }
835
+ /**
836
+ * Schedule async recomputation of handles for a node
837
+ */
838
+ scheduleRecomputeHandles(nodeId) {
839
+ // If no registry or node not found, skip
840
+ if (!this.registry)
841
+ return;
842
+ const node = this.graphStructure.getNode(nodeId);
843
+ if (!node)
844
+ return;
845
+ setTimeout(() => {
846
+ void this.recomputeHandlesForNode(nodeId);
847
+ }, 0);
848
+ }
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)
859
+ return;
860
+ const resolveHandles = desc.resolveHandles;
861
+ if (typeof resolveHandles !== "function")
862
+ 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)`);
886
+ }
887
+ return;
888
+ }
889
+ // Log resolveHandles-done
890
+ if (shouldLog) {
891
+ console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-done`);
892
+ }
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
+ }
923
+ }
924
+
925
+ /**
926
+ * ValuePropagator component - handles value propagation through edges
927
+ */
928
+ class ValuePropagator {
929
+ constructor(graphStructure, eventEmitter, runContextManager, handleResolver, runtimeCoordinator) {
930
+ this.arrayInputBuckets = new Map();
931
+ this.graphStructure = graphStructure;
932
+ this.eventEmitter = eventEmitter;
933
+ this.runContextManager = runContextManager;
934
+ 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;
942
+ }
943
+ /**
944
+ * Propagate value through edges
945
+ * @param runContextIds - Optional set of run-context IDs. If provided, propagation is run-context aware.
946
+ * If undefined or empty, behaves like auto mode (always propagates values and execution).
947
+ */
948
+ propagate(srcNodeId, srcHandle, value, runContextIds) {
949
+ // Set source output
950
+ const srcNode = this.graphStructure.getNode(srcNodeId);
951
+ if (!srcNode) {
952
+ // Node was removed (e.g., graph updated) but an async emit arrived late; ignore
953
+ return;
954
+ }
955
+ srcNode.outputs[srcHandle] = value;
956
+ this.eventEmitter.emit("value", {
957
+ nodeId: srcNodeId,
958
+ handle: srcHandle,
959
+ value,
960
+ io: "output",
961
+ runtimeTypeId: getTypedOutputTypeId(value),
962
+ });
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
+ });
997
+ continue;
998
+ }
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
+ });
1141
+ }
1142
+ else {
1143
+ // Synchronous conversion
1144
+ if (e.convert) {
1145
+ nextVal = e.convert(nextVal);
1146
+ }
1147
+ applyToTarget(nextVal);
1148
+ }
1149
+ }
1150
+ }
1151
+ /**
1152
+ * Propagate value in run-context aware mode (convenience method)
1153
+ */
1154
+ propagateInRunContexts(srcNodeId, srcHandle, value, runContextIds) {
1155
+ this.propagate(srcNodeId, srcHandle, value, runContextIds);
1156
+ }
1157
+ /**
1158
+ * Re-emit all outputs from a node (used when graph updates)
1159
+ * Only re-emits outputs that are valid according to resolved handles
1160
+ */
1161
+ reemitNodeOutputs(nodeId) {
1162
+ const node = this.graphStructure.getNode(nodeId);
1163
+ if (!node)
1164
+ return;
1165
+ // Get resolved handles to filter out invalid outputs
1166
+ const resolved = this.graphStructure.getResolvedHandles(nodeId);
1167
+ const validOutputHandles = resolved?.outputs
1168
+ ? new Set(Object.keys(resolved.outputs))
1169
+ : new Set();
1170
+ // 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
+ for (const [handle, value] of Object.entries(node.outputs)) {
1175
+ // Only re-emit if this handle is still valid
1176
+ if (validOutputHandles.has(handle)) {
1177
+ this.propagate(nodeId, handle, value, runContextIds);
1178
+ }
1179
+ }
1180
+ }
1181
+ /**
1182
+ * Clear array input buckets for a node (when node is deleted)
1183
+ */
1184
+ clearArrayBuckets(nodeId) {
1185
+ this.arrayInputBuckets.delete(nodeId);
1186
+ }
1187
+ /**
1188
+ * Clear all array buckets
1189
+ */
1190
+ clearAllArrayBuckets() {
1191
+ this.arrayInputBuckets.clear();
1192
+ }
1193
+ }
1194
+
1195
+ /**
1196
+ * ExecutionScheduler component - handles node execution scheduling and lifecycle
1197
+ */
1198
+ class ExecutionScheduler {
1199
+ constructor(graphStructure, eventEmitter, runContextManager, valuePropagator, runtimeCoordinator, environment) {
1200
+ this.environment = {};
1201
+ this.graphStructure = graphStructure;
1202
+ this.eventEmitter = eventEmitter;
1203
+ this.runContextManager = runContextManager;
1204
+ this.valuePropagator = valuePropagator;
1205
+ this.runtimeCoordinator = runtimeCoordinator;
1206
+ this.environment = environment ?? {};
1207
+ }
1208
+ setEnvironment(environment) {
1209
+ this.environment = environment;
1210
+ }
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
+ /**
1229
+ * Compute effective inputs for a node by merging real inputs with defaults
1230
+ */
1231
+ getEffectiveInputs(nodeId) {
1232
+ const node = this.graphStructure.getNode(nodeId);
1233
+ if (!node)
1234
+ return {};
1235
+ const registry = this.graphStructure.getRegistry();
1236
+ if (!registry)
1237
+ return {};
1238
+ const desc = registry.nodes.get(node.typeId);
1239
+ if (!desc)
1240
+ return {};
1241
+ const resolved = this.graphStructure.getResolvedHandles(nodeId);
1242
+ const regDefaults = desc.inputDefaults ?? {};
1243
+ const dynDefaults = resolved?.inputDefaults ?? {};
1244
+ // Identify which handles are dynamically resolved (not in registry statics)
1245
+ const staticHandles = new Set(Object.keys(desc.inputs ?? {}));
1246
+ const dynamicHandles = new Set(Object.keys(resolved?.inputs ?? {}).filter((h) => !staticHandles.has(h)));
1247
+ // Precedence: dynamic > registry
1248
+ const mergedDefaults = {
1249
+ ...regDefaults,
1250
+ ...dynDefaults,
1251
+ };
1252
+ // Start with real inputs only (no defaults)
1253
+ const effective = { ...node.inputs };
1254
+ // Build set of inbound handles (wired inputs)
1255
+ const edges = this.graphStructure.getEdges();
1256
+ const inbound = new Set(edges
1257
+ .filter((e) => e.target.nodeId === nodeId)
1258
+ .map((e) => e.target.handle));
1259
+ // Apply defaults only for:
1260
+ // 1. Unbound handles that have no explicit value
1261
+ // 2. Static handles (not dynamically resolved)
1262
+ for (const [handle, defaultValue] of Object.entries(mergedDefaults)) {
1263
+ if (defaultValue === undefined)
1264
+ continue;
1265
+ if (inbound.has(handle))
1266
+ continue; // Don't override wired inputs
1267
+ if (effective[handle] !== undefined)
1268
+ continue; // Already has value
1269
+ if (dynamicHandles.has(handle))
1270
+ continue; // Skip defaults for dynamic handles
1271
+ // Clone to avoid shared references
1272
+ effective[handle] = structuredClone(defaultValue);
1273
+ }
1274
+ return effective;
1275
+ }
1276
+ /**
1277
+ * Create an execution context for a node
1278
+ */
1279
+ createExecutionContext(nodeId, node, inputs, runId, abortSignal, runContextIds, options) {
1280
+ const emitHandler = options?.emitHandler ??
1281
+ ((handle, value) => {
1282
+ this.valuePropagator.propagate(nodeId, handle, value, runContextIds);
1283
+ });
1284
+ const reportProgress = options?.reportProgress ??
1285
+ ((p) => {
1286
+ node.stats.progress = Math.max(0, Math.min(1, Number(p) || 0));
1287
+ });
1288
+ // Create log function that respects node's logLevel
1289
+ const log = (level, message, context) => {
1290
+ const nodeLogLevel = node.logLevel ?? "info";
1291
+ const nodeLogValue = LOG_LEVEL_VALUES[nodeLogLevel] ?? 1;
1292
+ const requestedValue = LOG_LEVEL_VALUES[level] ?? 1;
1293
+ // Only log if requested level >= node's logLevel
1294
+ if (requestedValue >= nodeLogValue && nodeLogLevel !== "silent") {
1295
+ const contextStr = context
1296
+ ? ` ${Object.entries(context)
666
1297
  .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
667
1298
  .join(" ")}`
668
1299
  : "";
669
1300
  const fullMessage = `[node:${runId || nodeId}:${node.typeId}] ${message}${contextStr}`;
670
- // For other levels, use appropriate console method
671
1301
  switch (level) {
672
1302
  case "debug":
673
1303
  console.info(fullMessage);
@@ -684,15 +1314,20 @@ class GraphRuntime {
684
1314
  }
685
1315
  }
686
1316
  };
1317
+ // Store run-context IDs for use in scheduleInputsChanged
1318
+ const storedRunContextIds = runContextIds;
687
1319
  return {
688
1320
  nodeId,
689
1321
  state: node.state,
690
1322
  setState: (next) => Object.assign(node.state, next),
691
1323
  emit: emitHandler,
692
- invalidateDownstream: () => this.invalidateDownstreamInternal(nodeId),
1324
+ invalidateDownstream: () => {
1325
+ this.runtimeCoordinator.invalidateDownstream(nodeId);
1326
+ },
693
1327
  scheduleInputsChanged: () => {
694
1328
  if (this.allInboundHaveValue(nodeId)) {
695
- this.scheduleInputsChangedInternal(nodeId);
1329
+ // Preserve run-context IDs when scheduling from execution context
1330
+ this.scheduleInputsChangedInternal(nodeId, storedRunContextIds);
696
1331
  }
697
1332
  },
698
1333
  getInput: (handle) => inputs[handle],
@@ -703,12 +1338,33 @@ class GraphRuntime {
703
1338
  log,
704
1339
  };
705
1340
  }
706
- scheduleInputsChangedInternal(nodeId) {
707
- const node = this.nodes.get(nodeId);
1341
+ /**
1342
+ * Schedule a node for execution when its inputs change
1343
+ */
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);
708
1358
  if (!node)
709
1359
  return;
710
- if (this.paused)
1360
+ if (this.runtimeCoordinator.isPaused())
711
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);
1366
+ }
1367
+ }
712
1368
  const now = Date.now();
713
1369
  const policy = node.policy ?? {};
714
1370
  // Compute effective inputs (real inputs + defaults) for this execution
@@ -727,7 +1383,20 @@ class GraphRuntime {
727
1383
  node.runSeq += 1;
728
1384
  const rid = `${nodeId}:${node.runSeq}:${now}`;
729
1385
  node.latestRunId = rid;
730
- const startRun = (runId, capturedInputs, onDone) => {
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
+ }
731
1400
  const controller = new AbortController();
732
1401
  node.stats.runs += 1;
733
1402
  node.stats.active += 1;
@@ -746,7 +1415,7 @@ class GraphRuntime {
746
1415
  if (policy.timeoutMs && policy.timeoutMs > 0) {
747
1416
  timeoutId = setTimeout(() => controller.abort("timeout"), policy.timeoutMs);
748
1417
  }
749
- const ctx = this.createExecutionContext(nodeId, node, capturedInputs, runId, controller.signal, {
1418
+ const ctx = this.createExecutionContext(nodeId, node, capturedInputs, runId, controller.signal, runContextIdsForRun, {
750
1419
  emitHandler: (handle, value) => {
751
1420
  const m = policy.asyncConcurrency ?? "switch";
752
1421
  // Drop emits from runs that were explicitly cancelled due to a
@@ -755,11 +1424,11 @@ class GraphRuntime {
755
1424
  return;
756
1425
  if (m !== "merge" && runId !== node.latestRunId)
757
1426
  return;
758
- this.propagate(nodeId, handle, value);
1427
+ this.valuePropagator.propagate(nodeId, handle, value, runContextIdsForRun);
759
1428
  },
760
1429
  reportProgress: (p) => {
761
1430
  node.stats.progress = Math.max(0, Math.min(1, Number(p) || 0));
762
- this.emit("stats", {
1431
+ this.eventEmitter.emit("stats", {
763
1432
  kind: "node-progress",
764
1433
  nodeId,
765
1434
  typeId: node.typeId,
@@ -784,7 +1453,8 @@ class GraphRuntime {
784
1453
  const reason = controller.signal.reason;
785
1454
  if (reason === "switch" ||
786
1455
  reason === "snapshot" ||
787
- reason === "node-deleted") {
1456
+ reason === "node-deleted" ||
1457
+ reason === "user-cancelled") {
788
1458
  return; // Cancellation events are emitted separately, skip error handling
789
1459
  }
790
1460
  }
@@ -796,11 +1466,16 @@ class GraphRuntime {
796
1466
  await new Promise((r) => setTimeout(r, delay));
797
1467
  return exec(attempt + 1);
798
1468
  }
799
- this.emit("error", { kind: "node-run", nodeId, runId, err });
1469
+ this.eventEmitter.emit("error", {
1470
+ kind: "node-run",
1471
+ nodeId,
1472
+ runId,
1473
+ err,
1474
+ });
800
1475
  }
801
1476
  finally {
802
1477
  // Skip cleanup if node was deleted (cleanup already handled)
803
- if (!this.nodes.has(nodeId)) {
1478
+ if (!this.graphStructure.hasNode(nodeId)) {
804
1479
  return;
805
1480
  }
806
1481
  if (timeoutId)
@@ -818,9 +1493,10 @@ class GraphRuntime {
818
1493
  // Only emit node-done if not cancelled (cancellation events emitted separately)
819
1494
  const isCancelled = controller.signal.aborted &&
820
1495
  (controller.signal.reason === "snapshot" ||
821
- controller.signal.reason === "node-deleted");
1496
+ controller.signal.reason === "node-deleted" ||
1497
+ controller.signal.reason === "user-cancelled");
822
1498
  if (!isCancelled) {
823
- this.emit("stats", {
1499
+ this.eventEmitter.emit("stats", {
824
1500
  kind: "node-done",
825
1501
  nodeId,
826
1502
  typeId: node.typeId,
@@ -832,12 +1508,24 @@ class GraphRuntime {
832
1508
  durationMs: node.stats.lastDurationMs,
833
1509
  hadError,
834
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
+ }
835
1523
  if (onDone)
836
1524
  onDone();
837
1525
  }
838
1526
  };
839
- // fire node-start event
840
- this.emit("stats", {
1527
+ // Fire node-start event
1528
+ this.eventEmitter.emit("stats", {
841
1529
  kind: "node-start",
842
1530
  nodeId,
843
1531
  typeId: node.typeId,
@@ -847,8 +1535,10 @@ class GraphRuntime {
847
1535
  exec(0);
848
1536
  };
849
1537
  const mode = policy.asyncConcurrency ?? "switch";
850
- if (mode === "drop" && node.activeControllers.size > 0)
1538
+ if (mode === "drop" && node.activeControllers.size > 0) {
1539
+ // Don't increment pendingCount if we're dropping this run
851
1540
  return;
1541
+ }
852
1542
  if (mode === "queue") {
853
1543
  const maxQ = policy.maxQueue ?? 8;
854
1544
  node.queue.push({ runId: rid, inputs: effectiveInputs });
@@ -861,347 +1551,302 @@ class GraphRuntime {
861
1551
  if (!next)
862
1552
  return;
863
1553
  node.latestRunId = next.runId;
864
- startRun(next.runId, next.inputs, () => {
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, () => {
865
1559
  // After finishing, schedule next
866
- setTimeout(processNext, 0);
867
- });
868
- };
869
- processNext();
870
- return;
871
- }
872
- // switch or merge
873
- startRun(rid, effectiveInputs);
874
- }
875
- // Returns true if all inbound handles for the node currently have a value
876
- allInboundHaveValue(nodeId) {
877
- const node = this.nodes.get(nodeId);
878
- if (!node)
879
- return false;
880
- const inbound = this.edges.filter((e) => e.target.nodeId === nodeId);
881
- if (inbound.length === 0)
882
- return true;
883
- for (const e of inbound) {
884
- if (!(e.target.handle in node.inputs))
885
- return false;
886
- }
887
- return true;
888
- }
889
- // Computes effective inputs for a node by merging real inputs with defaults
890
- // Defaults are applied only for unbound handles that have no explicit value
891
- // This method does NOT mutate node.inputs - defaults remain virtual
892
- // Dynamic handles (from resolveHandles) do NOT get defaults applied - they are metadata-only
893
- getEffectiveInputs(nodeId) {
894
- const node = this.nodes.get(nodeId);
895
- if (!node)
896
- return {};
897
- const registry = this.registry;
898
- if (!registry)
899
- return {};
900
- const desc = registry.nodes.get(node.typeId);
901
- if (!desc)
902
- return {};
903
- const resolved = this.resolvedByNode.get(nodeId);
904
- const regDefaults = desc.inputDefaults ?? {};
905
- const dynDefaults = resolved?.inputDefaults ?? {};
906
- // Identify which handles are dynamically resolved (not in registry statics)
907
- // Dynamic handles are metadata-only and should not receive defaults in execution
908
- const staticHandles = new Set(Object.keys(desc.inputs ?? {}));
909
- const dynamicHandles = new Set(Object.keys(resolved?.inputs ?? {}).filter((h) => !staticHandles.has(h)));
910
- // Precedence: dynamic > registry
911
- const mergedDefaults = {
912
- ...regDefaults,
913
- ...dynDefaults,
914
- };
915
- // Start with real inputs only (no defaults)
916
- const effective = { ...node.inputs };
917
- // Build set of inbound handles (wired inputs)
918
- const inbound = new Set(this.edges
919
- .filter((e) => e.target.nodeId === nodeId)
920
- .map((e) => e.target.handle));
921
- // Apply defaults only for:
922
- // 1. Unbound handles that have no explicit value
923
- // 2. Static handles (not dynamically resolved)
924
- // This prevents dynamic handles (like geo:*) from getting defaults in execution
925
- // Dynamic handles are metadata-only for UI display, not execution values
926
- for (const [handle, defaultValue] of Object.entries(mergedDefaults)) {
927
- if (defaultValue === undefined)
928
- continue;
929
- if (inbound.has(handle))
930
- continue; // Don't override wired inputs
931
- if (effective[handle] !== undefined)
932
- continue; // Already has value
933
- if (dynamicHandles.has(handle))
934
- continue; // Skip defaults for dynamic handles
935
- // Clone to avoid shared references
936
- effective[handle] = structuredClone(defaultValue);
937
- }
938
- return effective;
939
- }
940
- invalidateDownstreamInternal(nodeId) {
941
- // Notifies dependents; for now we propagate current outputs
942
- for (const e of this.edges.filter((e) => e.source.nodeId === nodeId)) {
943
- const value = this.getOutput(nodeId, e.source.handle);
944
- if (value !== undefined)
945
- this.propagate(nodeId, e.source.handle, value);
946
- }
947
- }
948
- propagate(srcNodeId, srcHandle, value) {
949
- // set source output
950
- const srcNode = this.nodes.get(srcNodeId);
951
- if (!srcNode) {
952
- // Node was removed (e.g., graph updated) but an async emit arrived late; ignore
953
- return;
954
- }
955
- srcNode.outputs[srcHandle] = value;
956
- this.emit("value", {
957
- nodeId: srcNodeId,
958
- handle: srcHandle,
959
- value,
960
- io: "output",
961
- runtimeTypeId: getTypedOutputTypeId(value),
962
- });
963
- // fan-out along all edges from this output
964
- const outEdges = this.edges.filter((e) => e.source.nodeId === srcNodeId && e.source.handle === srcHandle);
965
- for (const e of outEdges) {
966
- // If source declares a union for this handle, require typed output
967
- const isUnion = Array.isArray(e.srcUnionTypes);
968
- const isTyped = isTypedOutput(value);
969
- if (isUnion && !isTyped) {
970
- const err = new Error(`Output ${srcNodeId}.${srcHandle} requires typed value for union output (allowed: ${e.srcUnionTypes.join("|")})`);
971
- this.emit("error", {
972
- kind: "edge-convert",
973
- edgeId: e.id,
974
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
975
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
976
- err,
977
- });
978
- continue;
979
- }
980
- // Clone per edge to isolate conversions from mutating the shared source value
981
- let nextVal = structuredClone(value);
982
- const applyToTarget = (v) => {
983
- const dstNode = this.nodes.get(e.target.nodeId);
984
- if (!dstNode)
985
- return;
986
- // Skip writing to unresolved handles - wait for handle resolution
987
- if (e.dstDeclared === undefined)
988
- return;
989
- const dstIsArray = typeof e.dstDeclared === "string" && e.dstDeclared.endsWith("[]");
990
- let next = v;
991
- // If target input is an array type, merge per-edge contributions deterministically
992
- if (dstIsArray) {
993
- const toArray = (x) => Array.isArray(x) ? x : x === undefined ? [] : [x];
994
- // Update this edge's contribution
995
- let forNode = this.arrayInputBuckets.get(e.target.nodeId);
996
- if (!forNode) {
997
- forNode = new Map();
998
- this.arrayInputBuckets.set(e.target.nodeId, forNode);
999
- }
1000
- let forHandle = forNode.get(e.target.handle);
1001
- if (!forHandle) {
1002
- forHandle = new Map();
1003
- forNode.set(e.target.handle, forHandle);
1004
- }
1005
- forHandle.set(e.id, toArray(v));
1006
- // Compute merged array in the order of current edges list
1007
- const merged = [];
1008
- for (const ed of this.edges) {
1009
- if (ed.target.nodeId === e.target.nodeId &&
1010
- ed.target.handle === e.target.handle) {
1011
- const part = forHandle.get(ed.id);
1012
- if (part && part.length)
1013
- merged.push(...part);
1014
- }
1015
- }
1016
- next = merged;
1017
- }
1018
- const prev = dstNode.inputs[e.target.handle];
1019
- const same = this.valuesEqual(prev, next);
1020
- if (!same) {
1021
- dstNode.inputs[e.target.handle] = next;
1022
- // Emit value event for input updates
1023
- this.emit("value", {
1024
- nodeId: e.target.nodeId,
1025
- handle: e.target.handle,
1026
- value: next,
1027
- io: "input",
1028
- runtimeTypeId: getTypedOutputTypeId(next),
1029
- });
1030
- // Recompute dynamic handles for the destination node on input change
1031
- this.scheduleRecomputeHandles(e.target.nodeId);
1032
- if (!this.paused && this.allInboundHaveValue(e.target.nodeId))
1033
- this.scheduleInputsChangedInternal(e.target.nodeId);
1034
- }
1035
- };
1036
- if (e.convertAsync) {
1037
- // emit edge-start before launching async conversion
1038
- this.emit("stats", {
1039
- kind: "edge-start",
1040
- edgeId: e.id,
1041
- typeId: e.typeId,
1042
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
1043
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
1044
- });
1045
- const controller = new AbortController();
1046
- const startAt = Date.now();
1047
- e.stats.runs += 1;
1048
- e.stats.inFlight = true;
1049
- e.stats.progress = 0;
1050
- const sig = controller.signal;
1051
- // Fire async conversion using edge's convertAsync (dynamic union aware)
1052
- e.convertAsync(nextVal, sig)
1053
- .then((v) => {
1054
- e.stats.inFlight = false;
1055
- e.stats.lastEndAt = Date.now();
1056
- e.stats.lastDurationMs = e.stats.lastEndAt - startAt;
1057
- // Clear lastError on successful conversion
1058
- e.stats.lastError = undefined;
1059
- this.emit("stats", {
1060
- kind: "edge-done",
1061
- edgeId: e.id,
1062
- typeId: e.typeId,
1063
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
1064
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
1065
- durationMs: e.stats.lastDurationMs,
1066
- });
1067
- applyToTarget(v);
1068
- })
1069
- .catch((err) => {
1070
- if (sig.aborted)
1071
- return;
1072
- e.stats.inFlight = false;
1073
- e.stats.lastError = err;
1074
- this.emit("error", {
1075
- kind: "edge-convert",
1076
- edgeId: e.id,
1077
- source: { nodeId: e.source.nodeId, handle: e.source.handle },
1078
- target: { nodeId: e.target.nodeId, handle: e.target.handle },
1079
- err,
1080
- });
1560
+ setTimeout(processNext, 0);
1561
+ });
1562
+ };
1563
+ processNext();
1564
+ return;
1565
+ }
1566
+ // switch or merge
1567
+ startRun(rid, effectiveInputs, runContextIdsForRun);
1568
+ }
1569
+ /**
1570
+ * Cancel all active runs for a node
1571
+ */
1572
+ cancelNodeActiveRuns(node, reason) {
1573
+ for (const controller of Array.from(node.activeControllers)) {
1574
+ const runId = node.controllerRunIds.get(controller);
1575
+ if (runId) {
1576
+ // Track cancelled runIds for snapshot and user-cancelled operations
1577
+ // (to drop emits from cancelled runs)
1578
+ if (reason === "snapshot" || reason === "user-cancelled") {
1579
+ if (!node.snapshotCancelledRunIds) {
1580
+ node.snapshotCancelledRunIds = new Set();
1581
+ }
1582
+ node.snapshotCancelledRunIds.add(runId);
1583
+ }
1584
+ // Emit cancellation event
1585
+ this.eventEmitter.emit("stats", {
1586
+ kind: "node-done",
1587
+ nodeId: node.nodeId,
1588
+ typeId: node.typeId,
1589
+ runId,
1590
+ cancelled: true,
1081
1591
  });
1082
1592
  }
1083
- else {
1084
- if (e.convert)
1085
- nextVal = e.convert(nextVal);
1086
- applyToTarget(nextVal);
1593
+ try {
1594
+ controller.abort(reason);
1595
+ }
1596
+ catch {
1597
+ // ignore abort errors
1087
1598
  }
1088
1599
  }
1600
+ node.activeControllers.clear();
1601
+ node.controllerRunIds.clear();
1602
+ node.stats.active = 0;
1603
+ node.queue = [];
1089
1604
  }
1090
- // Helper: build map of resolved handles per node from def (prefer def.resolvedHandles, otherwise registry statics)
1091
- static computeResolvedHandleMap(def, registry, environment) {
1092
- const out = new Map();
1093
- const pending = new Set();
1605
+ /**
1606
+ * Cancel runs for multiple nodes.
1607
+ * Can be called for snapshot/undo/redo operations or user-initiated cancellation.
1608
+ */
1609
+ cancelNodeRuns(nodeIds, reason = "user-cancelled") {
1610
+ if (nodeIds.length === 0)
1611
+ return;
1612
+ const toCancel = new Set(nodeIds);
1613
+ const visited = new Set();
1614
+ const queue = [...nodeIds];
1615
+ const edges = this.graphStructure.getEdges();
1616
+ // Collect all downstream nodes to cancel
1617
+ for (let i = 0; i < queue.length; i++) {
1618
+ const nodeId = queue[i];
1619
+ if (visited.has(nodeId))
1620
+ continue;
1621
+ visited.add(nodeId);
1622
+ for (const edge of edges) {
1623
+ if (edge.source.nodeId === nodeId) {
1624
+ const targetId = edge.target.nodeId;
1625
+ if (!visited.has(targetId)) {
1626
+ toCancel.add(targetId);
1627
+ queue.push(targetId);
1628
+ }
1629
+ }
1630
+ }
1631
+ }
1632
+ // Cancel runs for all affected nodes
1633
+ for (const nodeId of toCancel) {
1634
+ const node = this.graphStructure.getNode(nodeId);
1635
+ if (!node)
1636
+ continue;
1637
+ this.cancelNodeActiveRuns(node, reason);
1638
+ node.runSeq += 1;
1639
+ const now = Date.now();
1640
+ const suffix = reason === "snapshot" ? "snapshot" : "cancelled";
1641
+ node.latestRunId = `${nodeId}:${node.runSeq}:${now}:${suffix}`;
1642
+ }
1643
+ // Cancel nodes in run-contexts (exclude them from active run-contexts)
1644
+ for (const nodeId of toCancel) {
1645
+ this.runContextManager.cancelNodeInRunContexts(nodeId, false, // includeDownstream = false (already collected above)
1646
+ edges, this.graphStructure.getNodes());
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ // Types are now imported from components/types.ts (re-exported above)
1652
+ class GraphRuntime {
1653
+ constructor() {
1654
+ // State
1655
+ this.paused = false;
1656
+ this.environment = {};
1657
+ // Initialize components
1658
+ this.graphStructure = new GraphStructure();
1659
+ 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);
1681
+ }
1682
+ static create(def, registry, opts) {
1683
+ const gr = new GraphRuntime();
1684
+ gr.environment = opts?.environment ?? {};
1685
+ // Set registry and environment on components
1686
+ gr.graphStructure.setRegistry(registry);
1687
+ gr.handleResolver.setRegistry(registry);
1688
+ gr.handleResolver.setEnvironment(gr.environment);
1689
+ gr.executionScheduler.setEnvironment(gr.environment);
1690
+ // 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);
1694
+ }
1695
+ // Instantiate nodes
1094
1696
  for (const n of def.nodes) {
1095
1697
  const desc = registry.nodes.get(n.typeId);
1096
1698
  if (!desc)
1699
+ throw new Error(`Unknown node type: ${n.typeId}`);
1700
+ const cat = registry.categories.get(desc.categoryId);
1701
+ if (!cat)
1702
+ throw new Error(`Unknown category: ${desc.categoryId}`);
1703
+ if (cat.validateImpl)
1704
+ cat.validateImpl(desc.impl);
1705
+ const runtime = cat.createRuntime({
1706
+ nodeId: n.nodeId,
1707
+ impl: desc.impl,
1708
+ });
1709
+ const rn = {
1710
+ typeId: n.typeId,
1711
+ nodeId: n.nodeId,
1712
+ lifecycle: desc.lifecycle,
1713
+ inputs: {},
1714
+ outputs: {},
1715
+ state: {},
1716
+ runtime,
1717
+ params: n.params,
1718
+ policy: {
1719
+ ...cat.policy,
1720
+ ...desc.policy,
1721
+ ...n.params?.policy,
1722
+ },
1723
+ logLevel: desc.logLevel,
1724
+ runSeq: 0,
1725
+ activeControllers: new Set(),
1726
+ controllerRunIds: new Map(),
1727
+ queue: [],
1728
+ stats: {
1729
+ runs: 0,
1730
+ active: 0,
1731
+ queued: 0,
1732
+ progress: 0,
1733
+ },
1734
+ activeRunContexts: new Set(),
1735
+ };
1736
+ gr.graphStructure.setNode(n.nodeId, rn);
1737
+ }
1738
+ // Instantiate edges
1739
+ const edges = GraphStructure.buildEdges(def, registry, gr.graphStructure.getResolvedHandlesMap());
1740
+ gr.graphStructure.setEdges(edges);
1741
+ // Schedule async recompute only for nodes that indicated Promise-based resolveHandles
1742
+ for (const nodeId of initial.pending) {
1743
+ gr.handleResolver.scheduleRecomputeHandles(nodeId);
1744
+ }
1745
+ return gr;
1746
+ }
1747
+ on(event, handler) {
1748
+ return this.eventEmitter.on(event, handler);
1749
+ }
1750
+ setInputs(nodeId, inputs) {
1751
+ const node = this.graphStructure.getNode(nodeId);
1752
+ if (!node)
1753
+ throw new Error(`Node not found: ${nodeId}`);
1754
+ let anyChanged = false;
1755
+ const edges = this.graphStructure.getEdges();
1756
+ const registry = this.graphStructure.getRegistry();
1757
+ for (const [handle, value] of Object.entries(inputs)) {
1758
+ const hasInbound = edges.some((e) => e.target.nodeId === nodeId && e.target.handle === handle);
1759
+ if (hasInbound)
1097
1760
  continue;
1098
- const overrideInputs = n.resolvedHandles?.inputs;
1099
- const overrideOutputs = n.resolvedHandles?.outputs;
1100
- const overrideDefaults = n.resolvedHandles?.inputDefaults;
1101
- // Resolve dynamic handles if available (initial pass: inputs may be undefined)
1102
- let dyn = {};
1103
- try {
1104
- if (typeof desc.resolveHandles === "function") {
1105
- const maybe = desc.resolveHandles({
1106
- nodeId: n.nodeId,
1107
- environment: environment || {},
1108
- params: n.params,
1109
- inputs: undefined,
1110
- });
1111
- // Only use sync results here; async results are applied via recompute later
1112
- if (isPromise(maybe)) {
1113
- // mark node as pending async recompute
1114
- pending.add(n.nodeId);
1115
- }
1116
- else {
1117
- dyn = maybe || {};
1761
+ // Validate input value against declared type
1762
+ if (value !== undefined && registry) {
1763
+ const desc = registry.nodes.get(node.typeId);
1764
+ const resolved = this.graphStructure.getResolvedHandles(nodeId);
1765
+ // Get typeId from resolved handles first, then registry statics
1766
+ const typeId = resolved
1767
+ ? getInputTypeId(resolved.inputs, handle)
1768
+ : desc
1769
+ ? getInputTypeId(desc.inputs, handle)
1770
+ : undefined;
1771
+ if (typeId) {
1772
+ const typeDesc = registry.types.get(typeId);
1773
+ if (typeDesc?.validate && !typeDesc.validate(value)) {
1774
+ // Emit error event for invalid input value and reject it
1775
+ const errorMessage = `Invalid value for input ${nodeId}.${handle} (type ${typeId}): ${JSON.stringify(value)}`;
1776
+ this.eventEmitter.emit("error", {
1777
+ kind: "input-validation",
1778
+ nodeId,
1779
+ handle,
1780
+ typeId,
1781
+ value,
1782
+ message: errorMessage,
1783
+ });
1784
+ // Skip storing invalid value
1785
+ continue;
1118
1786
  }
1119
1787
  }
1120
1788
  }
1121
- catch {
1122
- // ignore dynamic resolution errors at this stage
1789
+ const prev = node.inputs[handle];
1790
+ const same = valuesEqual(prev, value);
1791
+ if (!same) {
1792
+ if (value === undefined) {
1793
+ delete node.inputs[handle];
1794
+ }
1795
+ else {
1796
+ node.inputs[handle] = value;
1797
+ }
1798
+ // Emit value event for input updates
1799
+ this.eventEmitter.emit("value", { nodeId, handle, value, io: "input" });
1800
+ anyChanged = true;
1123
1801
  }
1124
- // Merge base with dynamic and overrides (allow partial resolvedHandles)
1125
- const inputs = {
1126
- ...desc.inputs,
1127
- ...dyn.inputs,
1128
- ...overrideInputs,
1129
- };
1130
- const outputs = {
1131
- ...desc.outputs,
1132
- ...dyn.outputs,
1133
- ...overrideOutputs,
1134
- };
1135
- const inputDefaults = {
1136
- ...desc.inputDefaults,
1137
- ...dyn.inputDefaults,
1138
- ...overrideDefaults,
1139
- };
1140
- out.set(n.nodeId, { inputs, outputs, inputDefaults });
1141
1802
  }
1142
- return { map: out, pending };
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);
1810
+ }
1143
1811
  }
1144
- // Helper: build runtime edges with coercions using resolved handles
1145
- static buildEdges(def, registry, resolvedByNode) {
1146
- return def.edges.map((e) => {
1147
- const srcNode = def.nodes.find((n) => n.nodeId === e.source.nodeId);
1148
- const dstNode = def.nodes.find((n) => n.nodeId === e.target.nodeId);
1149
- let effectiveTypeId = e.typeId; // Start with original
1150
- let srcDeclared;
1151
- let dstDeclared;
1152
- if (srcNode) {
1153
- const resolved = resolvedByNode.get(srcNode.nodeId);
1154
- if (resolved)
1155
- srcDeclared = resolved.outputs[e.source.handle];
1156
- }
1157
- if (!effectiveTypeId) {
1158
- // Infer if not explicitly set
1159
- effectiveTypeId = Array.isArray(srcDeclared)
1160
- ? srcDeclared[0]
1161
- : srcDeclared;
1162
- }
1163
- if (dstNode) {
1164
- const resolved = resolvedByNode.get(dstNode.nodeId);
1165
- if (resolved)
1166
- dstDeclared = getInputTypeId(resolved.inputs, e.target.handle);
1167
- }
1168
- const { convert, convertAsync } = GraphRuntime.buildEdgeConverters(srcDeclared, dstDeclared, registry, `buildEdges: ${srcNode?.typeId || ""}.${e.source.nodeId}.${e.source.handle} -> ${dstNode?.typeId || ""}.${e.target.nodeId}.${e.target.handle}`);
1169
- return {
1170
- id: e.id,
1171
- source: { ...e.source },
1172
- target: { ...e.target },
1173
- typeId: e.typeId, // Preserve original (may be undefined)
1174
- effectiveTypeId: effectiveTypeId ?? "untyped", // Always present
1175
- convert,
1176
- convertAsync,
1177
- srcUnionTypes: Array.isArray(srcDeclared)
1178
- ? [...srcDeclared]
1179
- : undefined,
1180
- dstDeclared,
1181
- stats: { runs: 0, inFlight: false, progress: 0 },
1182
- };
1183
- });
1812
+ getOutput(nodeId, output) {
1813
+ const node = this.graphStructure.getNode(nodeId);
1814
+ return node?.outputs[output];
1184
1815
  }
1185
- reemitNodeOutputs(nodeId) {
1186
- const node = this.nodes.get(nodeId);
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);
1187
1819
  if (!node)
1188
1820
  return;
1189
- for (const [handle, value] of Object.entries(node.outputs)) {
1190
- this.propagate(nodeId, handle, value);
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
+ }
1191
1839
  }
1192
- }
1193
- // Update resolved handles for a single node and refresh edge converters/types that touch it
1194
- updateNodeHandles(nodeId, handles, registry) {
1195
- this.resolvedByNode.set(nodeId, handles);
1840
+ const edges = this.graphStructure.getEdges();
1196
1841
  // Recompute edge converter/type for edges where this node is source or target
1197
- for (const e of this.edges) {
1198
- const srcNode = this.nodes.get(e.source.nodeId);
1199
- const dstNode = this.nodes.get(e.target.nodeId);
1842
+ for (const e of edges) {
1843
+ const srcNode = this.graphStructure.getNode(e.source.nodeId);
1844
+ const dstNode = this.graphStructure.getNode(e.target.nodeId);
1200
1845
  let srcDeclared = e.effectiveTypeId; // Use effectiveTypeId as fallback
1201
1846
  let dstDeclared = e.dstDeclared;
1202
1847
  const oldDstDeclared = dstDeclared; // Track old value to detect resolution
1203
1848
  if (e.source.nodeId === nodeId) {
1204
- const resolved = this.resolvedByNode.get(nodeId);
1849
+ const resolved = this.graphStructure.getResolvedHandles(nodeId);
1205
1850
  srcDeclared = resolved
1206
1851
  ? resolved.outputs[e.source.handle]
1207
1852
  : srcDeclared;
@@ -1213,38 +1858,42 @@ class GraphRuntime {
1213
1858
  }
1214
1859
  }
1215
1860
  if (e.target.nodeId === nodeId) {
1216
- const resolved = this.resolvedByNode.get(nodeId);
1861
+ const resolved = this.graphStructure.getResolvedHandles(nodeId);
1217
1862
  if (resolved) {
1218
1863
  dstDeclared = getInputTypeId(resolved.inputs, e.target.handle);
1219
1864
  e.dstDeclared = dstDeclared;
1220
1865
  }
1221
1866
  }
1222
- const conv = GraphRuntime.buildEdgeConverters(srcDeclared, dstDeclared, registry, `updateNodeHandles: ${srcNode?.typeId || ""}.${e.source.nodeId}.${e.source.handle} -> ${dstNode?.typeId || ""}.${e.target.nodeId}.${e.target.handle}`);
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}`);
1223
1868
  e.convert = conv.convert;
1224
1869
  e.convertAsync = conv.convertAsync;
1225
1870
  // If target handle was just resolved (was undefined, now has a type), re-propagate values
1226
1871
  if (e.target.nodeId === nodeId &&
1227
1872
  oldDstDeclared === undefined &&
1228
1873
  dstDeclared !== undefined) {
1229
- const srcNode = this.nodes.get(e.source.nodeId);
1874
+ const srcNode = this.graphStructure.getNode(e.source.nodeId);
1230
1875
  if (srcNode) {
1231
1876
  const srcValue = srcNode.outputs[e.source.handle];
1232
1877
  if (srcValue !== undefined) {
1233
1878
  // Re-propagate through the now-resolved edge converter
1234
- this.propagate(e.source.nodeId, e.source.handle, srcValue);
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);
1235
1884
  }
1236
1885
  }
1237
1886
  }
1238
1887
  }
1239
- // Invalidate downstream for this node so UI refreshes
1240
- this.invalidateDownstreamInternal(nodeId);
1888
+ // Re-emit only valid outputs (after clearing removed ones)
1889
+ this.valuePropagator.reemitNodeOutputs(nodeId);
1241
1890
  }
1242
1891
  launch(invalidate = false) {
1243
1892
  // call onActivated for nodes that implement it
1244
- for (const node of this.nodes.values()) {
1245
- const effectiveInputs = this.getEffectiveInputs(node.nodeId);
1893
+ for (const node of this.graphStructure.getNodes().values()) {
1894
+ const effectiveInputs = this.executionScheduler.getEffectiveInputs(node.nodeId);
1246
1895
  const ctrl = new AbortController();
1247
- const ctx = this.createExecutionContext(node.nodeId, node, effectiveInputs, `${node.nodeId}:init`, ctrl.signal);
1896
+ const ctx = this.executionScheduler.createExecutionContext(node.nodeId, node, effectiveInputs, `${node.nodeId}:init`, ctrl.signal);
1248
1897
  if (node.lifecycle?.prepare) {
1249
1898
  ctx.log("debug", "prepare-start");
1250
1899
  node.lifecycle.prepare(node.params ?? {}, ctx);
@@ -1254,31 +1903,23 @@ class GraphRuntime {
1254
1903
  }
1255
1904
  if (invalidate) {
1256
1905
  // After activation, schedule nodes that have all inbound inputs present
1257
- for (const nodeId of this.nodes.keys()) {
1258
- if (this.allInboundHaveValue(nodeId))
1259
- this.scheduleInputsChangedInternal(nodeId);
1906
+ for (const nodeId of this.graphStructure.getNodes().keys()) {
1907
+ if (this.executionScheduler.allInboundHaveValue(nodeId))
1908
+ this.executionScheduler.scheduleInputsChangedInternal(nodeId);
1260
1909
  }
1261
1910
  }
1262
1911
  }
1263
1912
  triggerExternal(nodeId, event) {
1264
- const node = this.nodes.get(nodeId);
1913
+ const node = this.graphStructure.getNode(nodeId);
1265
1914
  if (!node)
1266
1915
  return;
1267
- // Built-in support: invalidate event reruns or re-emits without per-node wiring
1268
- if (event &&
1269
- typeof event === "object" &&
1270
- event.type === "invalidate") {
1271
- if (this.allInboundHaveValue(nodeId))
1272
- this.scheduleInputsChangedInternal(nodeId);
1273
- else
1274
- this.invalidateDownstreamInternal(nodeId);
1275
- }
1276
- else {
1277
- node.runtime.onExternalEvent?.(event, node.state);
1278
- }
1916
+ // Forward event to node's onExternalEvent handler for custom actions
1917
+ node.runtime.onExternalEvent?.(event, node.state);
1279
1918
  }
1280
1919
  dispose() {
1281
- for (const node of this.nodes.values()) {
1920
+ // Resolve all pending run-context promises before cleanup
1921
+ this.runContextManager.resolveAll();
1922
+ for (const node of this.graphStructure.getNodes().values()) {
1282
1923
  node.runtime.onDeactivated?.();
1283
1924
  node.runtime.dispose?.();
1284
1925
  node.lifecycle?.dispose?.({
@@ -1286,17 +1927,14 @@ class GraphRuntime {
1286
1927
  setState: (next) => Object.assign(node.state, next),
1287
1928
  });
1288
1929
  }
1289
- this.nodes.clear();
1290
- this.edges = [];
1291
- this.listeners.clear();
1292
- this.arrayInputBuckets.clear();
1930
+ this.graphStructure.clear();
1293
1931
  }
1294
1932
  getNodeIds() {
1295
- return Array.from(this.nodes.keys());
1933
+ return Array.from(this.graphStructure.getNodes().keys());
1296
1934
  }
1297
1935
  // Unsafe helpers for serializer: read-only accessors and hydration
1298
1936
  getNodeData(nodeId) {
1299
- const node = this.nodes.get(nodeId);
1937
+ const node = this.graphStructure.getNode(nodeId);
1300
1938
  if (!node)
1301
1939
  return undefined;
1302
1940
  return {
@@ -1312,15 +1950,17 @@ class GraphRuntime {
1312
1950
  }
1313
1951
  setEnvironment(env) {
1314
1952
  this.environment = { ...env };
1953
+ this.handleResolver.setEnvironment(this.environment);
1954
+ this.executionScheduler.setEnvironment(this.environment);
1315
1955
  // Recompute dynamic handles for all nodes when environment changes
1316
- for (const nodeId of this.nodes.keys()) {
1317
- this.scheduleRecomputeHandles(nodeId);
1956
+ for (const nodeId of this.graphStructure.getNodes().keys()) {
1957
+ this.handleResolver.scheduleRecomputeHandles(nodeId);
1318
1958
  }
1319
1959
  }
1320
1960
  // Export a GraphDefinition reflecting the current runtime view
1321
1961
  getGraphDef() {
1322
- const nodes = Array.from(this.nodes.values()).map((n) => {
1323
- const resolved = this.resolvedByNode.get(n.nodeId);
1962
+ const nodes = Array.from(this.graphStructure.getNodes().values()).map((n) => {
1963
+ const resolved = this.graphStructure.getResolvedHandles(n.nodeId);
1324
1964
  return {
1325
1965
  nodeId: n.nodeId,
1326
1966
  typeId: n.typeId,
@@ -1328,7 +1968,9 @@ class GraphRuntime {
1328
1968
  resolvedHandles: resolved ? { ...resolved } : undefined,
1329
1969
  };
1330
1970
  });
1331
- const edges = this.edges.map((e) => ({
1971
+ const edges = this.graphStructure
1972
+ .getEdges()
1973
+ .map((e) => ({
1332
1974
  id: e.id,
1333
1975
  source: { nodeId: e.source.nodeId, handle: e.source.handle },
1334
1976
  target: { nodeId: e.target.nodeId, handle: e.target.handle },
@@ -1337,8 +1979,23 @@ class GraphRuntime {
1337
1979
  return { nodes, edges };
1338
1980
  }
1339
1981
  async whenIdle() {
1982
+ // If we have active run-contexts, wait for all of them to complete
1983
+ const allRunContexts = this.runContextManager.getAllRunContexts();
1984
+ if (allRunContexts.size > 0) {
1985
+ await new Promise((resolve) => {
1986
+ const check = () => {
1987
+ if (this.runContextManager.getAllRunContexts().size === 0) {
1988
+ resolve();
1989
+ }
1990
+ else {
1991
+ setTimeout(check, 10);
1992
+ }
1993
+ };
1994
+ setTimeout(check, 10);
1995
+ });
1996
+ }
1340
1997
  const isIdle = () => {
1341
- for (const n of this.nodes.values()) {
1998
+ for (const n of this.graphStructure.getNodes().values()) {
1342
1999
  if (n.activeControllers.size > 0)
1343
2000
  return false;
1344
2001
  if (n.queue.length > 0)
@@ -1358,6 +2015,56 @@ class GraphRuntime {
1358
2015
  setTimeout(check, 10);
1359
2016
  });
1360
2017
  }
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
+ 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);
2060
+ if (!node)
2061
+ 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
+ }
1361
2068
  pause() {
1362
2069
  this.paused = true;
1363
2070
  }
@@ -1368,80 +2075,23 @@ class GraphRuntime {
1368
2075
  this.paused = false;
1369
2076
  }
1370
2077
  invalidateDownstream(nodeId) {
1371
- this.invalidateDownstreamInternal(nodeId);
2078
+ this.valuePropagator.reemitNodeOutputs(nodeId);
1372
2079
  }
1373
2080
  scheduleInputsChanged(nodeId) {
1374
- this.scheduleInputsChangedInternal(nodeId);
1375
- }
1376
- /**
1377
- * Cancel all active runs for a node and emit cancellation events
1378
- * @param node - The node to cancel runs for
1379
- * @param reason - The cancellation reason ("snapshot" | "node-deleted")
1380
- */
1381
- cancelNodeActiveRuns(node, reason) {
1382
- for (const controller of Array.from(node.activeControllers)) {
1383
- const runId = node.controllerRunIds.get(controller);
1384
- if (runId) {
1385
- // Track cancelled runIds for snapshot operations
1386
- if (reason === "snapshot") {
1387
- if (!node.snapshotCancelledRunIds) {
1388
- node.snapshotCancelledRunIds = new Set();
1389
- }
1390
- node.snapshotCancelledRunIds.add(runId);
1391
- }
1392
- // Emit cancellation event
1393
- this.emit("stats", {
1394
- kind: "node-done",
1395
- nodeId: node.nodeId,
1396
- typeId: node.typeId,
1397
- runId,
1398
- cancelled: true,
1399
- });
1400
- }
1401
- try {
1402
- controller.abort(reason);
1403
- }
1404
- catch {
1405
- // ignore abort errors
1406
- }
1407
- }
1408
- node.activeControllers.clear();
1409
- node.controllerRunIds.clear();
1410
- node.stats.active = 0;
1411
- node.queue = [];
2081
+ this.executionScheduler.scheduleInputsChangedInternal(nodeId);
1412
2082
  }
1413
2083
  cancelNodeRuns(nodeIds) {
1414
- if (nodeIds.length === 0)
2084
+ this.executionScheduler.cancelNodeRuns(nodeIds);
2085
+ }
2086
+ copyOutputs(fromNodeId, toNodeId, options) {
2087
+ // Get outputs from source node
2088
+ const fromNode = this.getNodeData(fromNodeId);
2089
+ if (!fromNode?.outputs)
1415
2090
  return;
1416
- const toCancel = new Set(nodeIds);
1417
- const visited = new Set();
1418
- const queue = [...nodeIds];
1419
- // Collect all downstream nodes to cancel
1420
- for (let i = 0; i < queue.length; i++) {
1421
- const nodeId = queue[i];
1422
- if (visited.has(nodeId))
1423
- continue;
1424
- visited.add(nodeId);
1425
- for (const edge of this.edges) {
1426
- if (edge.source.nodeId === nodeId) {
1427
- const targetId = edge.target.nodeId;
1428
- if (!visited.has(targetId)) {
1429
- toCancel.add(targetId);
1430
- queue.push(targetId);
1431
- }
1432
- }
1433
- }
1434
- }
1435
- // Cancel runs for all affected nodes
1436
- for (const nodeId of toCancel) {
1437
- const node = this.nodes.get(nodeId);
1438
- if (!node)
1439
- continue;
1440
- this.cancelNodeActiveRuns(node, "snapshot");
1441
- node.runSeq += 1;
1442
- const now = Date.now();
1443
- node.latestRunId = `${nodeId}:${node.runSeq}:${now}:snapshot`;
1444
- }
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 });
1445
2095
  }
1446
2096
  // Hydrate inputs/outputs without triggering computation; optionally re-emit outputs downstream
1447
2097
  hydrate(payload, opts) {
@@ -1450,13 +2100,13 @@ class GraphRuntime {
1450
2100
  try {
1451
2101
  const ins = payload?.inputs || {};
1452
2102
  for (const [nodeId, map] of Object.entries(ins)) {
1453
- const node = this.nodes.get(nodeId);
2103
+ const node = this.graphStructure.getNode(nodeId);
1454
2104
  if (!node)
1455
2105
  continue;
1456
2106
  for (const [h, v] of Object.entries(map || {})) {
1457
2107
  node.inputs[h] = structuredClone(v);
1458
2108
  // emit input value event
1459
- this.emit("value", {
2109
+ this.eventEmitter.emit("value", {
1460
2110
  nodeId,
1461
2111
  handle: h,
1462
2112
  value: node.inputs[h],
@@ -1467,13 +2117,13 @@ class GraphRuntime {
1467
2117
  }
1468
2118
  const outs = payload?.outputs || {};
1469
2119
  for (const [nodeId, map] of Object.entries(outs)) {
1470
- const node = this.nodes.get(nodeId);
2120
+ const node = this.graphStructure.getNode(nodeId);
1471
2121
  if (!node)
1472
2122
  continue;
1473
2123
  for (const [h, v] of Object.entries(map || {})) {
1474
2124
  node.outputs[h] = structuredClone(v);
1475
2125
  // emit output value event
1476
- this.emit("value", {
2126
+ this.eventEmitter.emit("value", {
1477
2127
  nodeId,
1478
2128
  handle: h,
1479
2129
  value: node.outputs[h],
@@ -1483,8 +2133,9 @@ class GraphRuntime {
1483
2133
  }
1484
2134
  }
1485
2135
  if (opts?.reemit) {
1486
- for (const nodeId of this.nodes.keys())
1487
- this.reemitNodeOutputs(nodeId);
2136
+ for (const nodeId of this.graphStructure.getNodes().keys()) {
2137
+ this.valuePropagator.reemitNodeOutputs(nodeId);
2138
+ }
1488
2139
  }
1489
2140
  }
1490
2141
  finally {
@@ -1495,13 +2146,23 @@ class GraphRuntime {
1495
2146
  update(def, registry) {
1496
2147
  // Handle node additions and removals
1497
2148
  const desiredIds = new Set(def.nodes.map((n) => n.nodeId));
1498
- const currentIds = new Set(this.nodes.keys());
2149
+ const currentIds = new Set(this.graphStructure.getNodes().keys());
1499
2150
  // Remove nodes not present
1500
2151
  for (const nodeId of Array.from(currentIds)) {
1501
2152
  if (!desiredIds.has(nodeId)) {
1502
- const node = this.nodes.get(nodeId);
2153
+ const node = this.graphStructure.getNode(nodeId);
1503
2154
  // Cancel all active runs and emit cancellation events
1504
- this.cancelNodeActiveRuns(node, "node-deleted");
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
+ }
2165
+ }
1505
2166
  // Cleanup node resources
1506
2167
  node.runtime.onDeactivated?.();
1507
2168
  node.runtime.dispose?.();
@@ -1509,13 +2170,13 @@ class GraphRuntime {
1509
2170
  state: node.state,
1510
2171
  setState: (next) => Object.assign(node.state, next),
1511
2172
  });
1512
- this.nodes.delete(nodeId);
1513
- this.arrayInputBuckets.delete(nodeId);
2173
+ this.graphStructure.deleteNode(nodeId);
2174
+ this.valuePropagator.clearArrayBuckets(nodeId);
1514
2175
  }
1515
2176
  }
1516
2177
  // Add or update existing nodes
1517
2178
  for (const n of def.nodes) {
1518
- const existing = this.nodes.get(n.nodeId);
2179
+ const existing = this.graphStructure.getNode(n.nodeId);
1519
2180
  if (!existing) {
1520
2181
  // create new runtime node
1521
2182
  const desc = registry.nodes.get(n.typeId);
@@ -1554,12 +2215,13 @@ class GraphRuntime {
1554
2215
  queued: 0,
1555
2216
  progress: 0,
1556
2217
  },
2218
+ activeRunContexts: new Set(),
1557
2219
  };
1558
- this.nodes.set(n.nodeId, rn);
2220
+ this.graphStructure.setNode(n.nodeId, rn);
1559
2221
  // Activate new node
1560
- const effectiveInputs = this.getEffectiveInputs(rn.nodeId);
2222
+ const effectiveInputs = this.executionScheduler.getEffectiveInputs(rn.nodeId);
1561
2223
  const ctrl = new AbortController();
1562
- const ctx = this.createExecutionContext(rn.nodeId, rn, effectiveInputs, `${rn.nodeId}:init`, ctrl.signal);
2224
+ const ctx = this.executionScheduler.createExecutionContext(rn.nodeId, rn, effectiveInputs, `${rn.nodeId}:init`, ctrl.signal);
1563
2225
  if (rn.lifecycle?.prepare) {
1564
2226
  ctx.log("debug", "prepare-start");
1565
2227
  rn.lifecycle.prepare(rn.params ?? {}, ctx);
@@ -1581,15 +2243,16 @@ class GraphRuntime {
1581
2243
  }
1582
2244
  }
1583
2245
  // Capture previous inbound map before rebuilding edges
2246
+ const edges = this.graphStructure.getEdges();
1584
2247
  const prevInbound = new Map();
1585
- for (const e of this.edges) {
2248
+ for (const e of edges) {
1586
2249
  const set = prevInbound.get(e.target.nodeId) ?? new Set();
1587
2250
  set.add(e.target.handle);
1588
2251
  prevInbound.set(e.target.nodeId, set);
1589
2252
  }
1590
2253
  // Capture previous per-handle target sets before rebuilding edges
1591
2254
  const prevOutTargets = new Map();
1592
- for (const e of this.edges) {
2255
+ for (const e of edges) {
1593
2256
  const tmap = prevOutTargets.get(e.source.nodeId) ?? new Map();
1594
2257
  const tset = tmap.get(e.source.handle) ?? new Set();
1595
2258
  tset.add(`${e.target.nodeId}.${e.target.handle}`);
@@ -1597,22 +2260,27 @@ class GraphRuntime {
1597
2260
  prevOutTargets.set(e.source.nodeId, tmap);
1598
2261
  }
1599
2262
  // Precompute per-node resolved handles for updated graph (include dynamic)
1600
- const resolved = GraphRuntime.computeResolvedHandleMap(def, registry, this.environment);
2263
+ const resolved = GraphStructure.computeResolvedHandleMap(def, registry, this.environment);
1601
2264
  // Check which handles changed and emit events for those
1602
2265
  const changedHandles = {};
1603
2266
  for (const [nodeId, newHandles] of resolved.map) {
1604
- const oldHandles = this.resolvedByNode.get(nodeId);
2267
+ const oldHandles = this.graphStructure.getResolvedHandles(nodeId);
1605
2268
  if (!oldHandles ||
1606
2269
  JSON.stringify(oldHandles) !== JSON.stringify(newHandles)) {
1607
2270
  changedHandles[nodeId] = newHandles;
1608
2271
  }
1609
2272
  }
1610
- this.resolvedByNode = resolved.map;
2273
+ // Update resolved handles
2274
+ for (const [nodeId, handles] of resolved.map) {
2275
+ this.graphStructure.setResolvedHandles(nodeId, handles);
2276
+ }
1611
2277
  // Rebuild edges mapping with coercions
1612
- this.edges = GraphRuntime.buildEdges(def, registry, this.resolvedByNode);
2278
+ const newEdges = GraphStructure.buildEdges(def, registry, this.graphStructure.getResolvedHandlesMap());
2279
+ this.graphStructure.setEdges(newEdges);
1613
2280
  // Build new inbound map
1614
2281
  const nextInbound = new Map();
1615
- for (const e of this.edges) {
2282
+ const updatedEdges = this.graphStructure.getEdges();
2283
+ for (const e of updatedEdges) {
1616
2284
  const set = nextInbound.get(e.target.nodeId) ?? new Set();
1617
2285
  set.add(e.target.handle);
1618
2286
  nextInbound.set(e.target.nodeId, set);
@@ -1620,7 +2288,7 @@ class GraphRuntime {
1620
2288
  // For inputs that lost inbound connections, clear and schedule recompute
1621
2289
  for (const [nodeId, prevSet] of prevInbound) {
1622
2290
  const currSet = nextInbound.get(nodeId) ?? new Set();
1623
- const node = this.nodes.get(nodeId);
2291
+ const node = this.graphStructure.getNode(nodeId);
1624
2292
  if (!node)
1625
2293
  continue;
1626
2294
  let changed = false;
@@ -1633,22 +2301,14 @@ class GraphRuntime {
1633
2301
  }
1634
2302
  }
1635
2303
  if (changed) {
1636
- // Clear buckets for handles that lost inbound
1637
- const bucketsForNode = this.arrayInputBuckets.get(nodeId);
1638
- if (bucketsForNode) {
1639
- for (const handle of Array.from(prevSet)) {
1640
- if (!currSet.has(handle))
1641
- bucketsForNode.delete(handle);
1642
- }
1643
- if (bucketsForNode.size === 0)
1644
- this.arrayInputBuckets.delete(nodeId);
1645
- }
1646
- this.scheduleInputsChangedInternal(nodeId);
2304
+ // Clear buckets for handles that lost inbound (handled by ValuePropagator)
2305
+ this.valuePropagator.clearArrayBuckets(nodeId);
2306
+ this.executionScheduler.scheduleInputsChangedInternal(nodeId);
1647
2307
  }
1648
2308
  }
1649
2309
  // Re-emit outputs when per-handle target sets change (precise and simple)
1650
2310
  const nextOutTargets = new Map();
1651
- for (const e of this.edges) {
2311
+ for (const e of updatedEdges) {
1652
2312
  const tmap = nextOutTargets.get(e.source.nodeId) ?? new Map();
1653
2313
  const tset = tmap.get(e.source.handle) ?? new Set();
1654
2314
  tset.add(`${e.target.nodeId}.${e.target.handle}`);
@@ -1684,119 +2344,26 @@ class GraphRuntime {
1684
2344
  if (!setsEqualStr(pset, nset)) {
1685
2345
  const val = this.getOutput(nodeId, handle);
1686
2346
  if (val !== undefined)
1687
- this.propagate(nodeId, handle, val);
1688
- else if (this.allInboundHaveValue(nodeId))
1689
- this.scheduleInputsChangedInternal(nodeId);
2347
+ this.valuePropagator.propagate(nodeId, handle, val);
2348
+ else if (this.executionScheduler.allInboundHaveValue(nodeId))
2349
+ this.executionScheduler.scheduleInputsChangedInternal(nodeId);
1690
2350
  }
1691
2351
  }
1692
2352
  }
1693
2353
  // Prune array bucket contributions for edges that no longer exist
1694
- const validPerTarget = new Map();
1695
- for (const ed of this.edges) {
1696
- const m = validPerTarget.get(ed.target.nodeId) ?? new Map();
1697
- const s = m.get(ed.target.handle) ?? new Set();
1698
- s.add(ed.id);
1699
- m.set(ed.target.handle, s);
1700
- validPerTarget.set(ed.target.nodeId, m);
1701
- }
1702
- for (const [nodeId, byHandle] of Array.from(this.arrayInputBuckets)) {
1703
- const validHandles = validPerTarget.get(nodeId) ?? new Map();
1704
- for (const [handle, perEdge] of Array.from(byHandle)) {
1705
- const validEdgeIds = validHandles.get(handle) ?? new Set();
1706
- for (const edgeId of Array.from(perEdge.keys())) {
1707
- if (!validEdgeIds.has(edgeId))
1708
- perEdge.delete(edgeId);
1709
- }
1710
- if (perEdge.size === 0)
1711
- byHandle.delete(handle);
1712
- }
1713
- if (byHandle.size === 0)
1714
- this.arrayInputBuckets.delete(nodeId);
1715
- }
2354
+ // This is handled by ValuePropagator - array buckets are managed there
2355
+ // The buckets will be cleaned up automatically when edges are removed
1716
2356
  // Schedule async recompute for nodes that indicated Promise-based resolveHandles in this update
1717
2357
  // Emit event for changed handles (if any)
1718
2358
  if (Object.keys(changedHandles).length > 0) {
1719
- this.emit("invalidate", {
2359
+ this.eventEmitter.emit("invalidate", {
1720
2360
  reason: "graph-updated",
1721
2361
  resolvedHandles: changedHandles,
1722
2362
  });
1723
2363
  }
1724
2364
  for (const nodeId of resolved.pending) {
1725
- this.scheduleRecomputeHandles(nodeId);
1726
- }
1727
- }
1728
- // Schedule a recomputation of dynamic handles for a node (async to avoid mutating during propagation)
1729
- scheduleRecomputeHandles(nodeId) {
1730
- // If no registry or node not found, skip
1731
- if (!this.registry)
1732
- return;
1733
- const node = this.nodes.get(nodeId);
1734
- if (!node)
1735
- return;
1736
- setTimeout(() => {
1737
- void this.recomputeHandlesForNode(nodeId);
1738
- }, 0);
1739
- }
1740
- // Recompute dynamic handles for a single node using current inputs/environment
1741
- async recomputeHandlesForNode(nodeId) {
1742
- const registry = this.registry;
1743
- const node = this.nodes.get(nodeId);
1744
- if (!node)
1745
- return;
1746
- const desc = registry.nodes.get(node.typeId);
1747
- if (!desc)
1748
- return;
1749
- const resolveHandles = desc.resolveHandles;
1750
- if (typeof resolveHandles !== "function")
1751
- return;
1752
- const token = (this.recomputeTokenByNode.get(nodeId) ?? 0) + 1;
1753
- this.recomputeTokenByNode.set(nodeId, token);
1754
- // Log resolveHandles-start
1755
- const nodeLogLevel = node.logLevel ?? "info";
1756
- const nodeLogValue = LOG_LEVEL_VALUES[nodeLogLevel] ?? 1;
1757
- const shouldLog = nodeLogValue <= LOG_LEVEL_VALUES.debug && nodeLogLevel !== "silent";
1758
- if (shouldLog) {
1759
- console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-start`);
1760
- }
1761
- let r;
1762
- try {
1763
- const res = resolveHandles({
1764
- nodeId,
1765
- environment: this.environment || {},
1766
- params: node.params,
1767
- inputs: node.inputs || {},
1768
- });
1769
- r = await unwrapMaybePromise(res);
1770
- }
1771
- catch {
1772
- // Log resolveHandles-done even on error
1773
- if (shouldLog) {
1774
- console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-done (error)`);
1775
- }
1776
- return;
1777
- }
1778
- // Log resolveHandles-done
1779
- if (shouldLog) {
1780
- console.info(`[node:${nodeId}:${node.typeId}] resolveHandles-done`);
2365
+ this.handleResolver.scheduleRecomputeHandles(nodeId);
1781
2366
  }
1782
- // If a newer recompute was scheduled, drop this result
1783
- if ((this.recomputeTokenByNode.get(nodeId) ?? 0) !== token)
1784
- return;
1785
- const inputs = { ...desc.inputs, ...r?.inputs };
1786
- const outputs = { ...desc.outputs, ...r?.outputs };
1787
- const inputDefaults = { ...desc.inputDefaults, ...r?.inputDefaults };
1788
- const next = { inputs, outputs, inputDefaults };
1789
- const before = this.resolvedByNode.get(nodeId);
1790
- // Compare shallow-structurally via JSON
1791
- if (JSON.stringify(before) === JSON.stringify(next))
1792
- return;
1793
- this.resolvedByNode.set(nodeId, next);
1794
- this.updateNodeHandles(nodeId, next, registry);
1795
- // Notify graph updated with the changed handles
1796
- this.emit("invalidate", {
1797
- reason: "graph-updated",
1798
- resolvedHandles: { [nodeId]: next },
1799
- });
1800
2367
  }
1801
2368
  }
1802
2369
 
@@ -2022,14 +2589,39 @@ class AbstractEngine {
2022
2589
  constructor(graphRuntime) {
2023
2590
  this.graphRuntime = graphRuntime;
2024
2591
  }
2025
- launch(invalidate = false) {
2026
- this.graphRuntime.launch(invalidate);
2027
- }
2028
- setInputs(nodeId, inputs) {
2029
- this.graphRuntime.setInputs(nodeId, inputs);
2592
+ setInputs(nodeId, inputs, options) {
2593
+ if (options?.dry) {
2594
+ const wasPaused = this.graphRuntime.isPaused();
2595
+ if (!wasPaused)
2596
+ this.graphRuntime.pause();
2597
+ try {
2598
+ this.graphRuntime.setInputs(nodeId, inputs);
2599
+ }
2600
+ finally {
2601
+ if (!wasPaused)
2602
+ this.graphRuntime.resume();
2603
+ }
2604
+ }
2605
+ else {
2606
+ this.graphRuntime.setInputs(nodeId, inputs);
2607
+ }
2030
2608
  }
2031
- triggerExternal(nodeId, event) {
2032
- this.graphRuntime.triggerExternal(nodeId, event);
2609
+ triggerExternal(nodeId, event, options) {
2610
+ if (options?.dry) {
2611
+ const wasPaused = this.graphRuntime.isPaused();
2612
+ if (!wasPaused)
2613
+ this.graphRuntime.pause();
2614
+ try {
2615
+ this.graphRuntime.triggerExternal(nodeId, event);
2616
+ }
2617
+ finally {
2618
+ if (!wasPaused)
2619
+ this.graphRuntime.resume();
2620
+ }
2621
+ }
2622
+ else {
2623
+ this.graphRuntime.triggerExternal(nodeId, event);
2624
+ }
2033
2625
  }
2034
2626
  on(event, handler) {
2035
2627
  return this.graphRuntime.on(event, handler);
@@ -2040,205 +2632,68 @@ class AbstractEngine {
2040
2632
  whenIdle() {
2041
2633
  return this.graphRuntime.whenIdle();
2042
2634
  }
2635
+ cancelNodeRuns(nodeIds) {
2636
+ this.graphRuntime.cancelNodeRuns(nodeIds);
2637
+ }
2638
+ copyOutputs(fromNodeId, toNodeId, options) {
2639
+ this.graphRuntime.copyOutputs(fromNodeId, toNodeId, options);
2640
+ }
2043
2641
  dispose() {
2044
2642
  // this.graphRuntime.dispose();
2045
2643
  }
2046
2644
  }
2047
2645
 
2048
- class PushEngine extends AbstractEngine {
2049
- constructor(graphRuntime) {
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) {
2050
2653
  super(graphRuntime);
2654
+ this.runMode = "manual";
2655
+ this.setRunMode(runMode ?? "manual");
2051
2656
  }
2052
- launch() {
2053
- super.launch();
2054
- this.graphRuntime.resume();
2055
- }
2056
- }
2057
-
2058
- class BatchedEngine extends AbstractEngine {
2059
- constructor(graphRuntime, opts = {}) {
2060
- super(graphRuntime);
2061
- this.opts = opts;
2062
- this.dirtyNodes = new Set();
2657
+ launch(invalidate, runMode) {
2658
+ if (runMode)
2659
+ this.setRunMode(runMode);
2660
+ this.graphRuntime.launch(invalidate);
2063
2661
  }
2064
- launch() {
2065
- this.graphRuntime.pause();
2066
- if (this.opts.flushIntervalMs && this.opts.flushIntervalMs > 0) {
2067
- this.timer = setInterval(() => this.flush(), this.opts.flushIntervalMs);
2068
- }
2662
+ /**
2663
+ * Run only this node, no downstream propagation.
2664
+ * Works in both modes, but typically only used in manual mode.
2665
+ * @param nodeId - The node to run
2666
+ * @param options - Execution options
2667
+ * @param options.skipPropagateValues - If true, don't set inputs of linked nodes (default: false)
2668
+ */
2669
+ async computeNode(nodeId, options) {
2670
+ await this.graphRuntime.runFromHereContext(nodeId, {
2671
+ skipPropagateValues: options?.skipPropagateValues ?? false,
2672
+ propagate: false, // Don't schedule downstream nodes
2673
+ });
2069
2674
  }
2070
- setInputs(nodeId, inputs) {
2071
- super.setInputs(nodeId, inputs);
2072
- this.dirtyNodes.add(nodeId);
2675
+ /**
2676
+ * Run this node and all dynamically reachable downstream nodes.
2677
+ * Works in both modes, but typically only used in manual mode.
2678
+ * Uses run-context system for dynamic graph updates.
2679
+ */
2680
+ async runFromHere(nodeId) {
2681
+ await this.graphRuntime.runFromHereContext(nodeId);
2073
2682
  }
2074
- triggerExternal(nodeId, event) {
2075
- super.triggerExternal(nodeId, event);
2076
- this.dirtyNodes.add(nodeId);
2683
+ getRunMode() {
2684
+ return this.runMode;
2077
2685
  }
2078
- async flush() {
2079
- if (this.dirtyNodes.size === 0)
2686
+ setRunMode(runMode) {
2687
+ if (this.runMode === runMode)
2080
2688
  return;
2081
- // Resume, schedule dirty nodes, wait idle, then pause again
2082
- const nodes = Array.from(this.dirtyNodes);
2083
- this.dirtyNodes.clear();
2084
- this.graphRuntime.resume();
2085
- for (const n of nodes)
2086
- this.graphRuntime.scheduleInputsChanged(n);
2087
- await this.graphRuntime.whenIdle();
2088
- this.graphRuntime.pause();
2089
- }
2090
- dispose() {
2091
- if (this.timer)
2092
- clearInterval(this.timer);
2093
- super.dispose();
2094
- }
2095
- }
2096
-
2097
- // PullEngine computes only when asked, otherwise holds inputs without scheduling
2098
- class PullEngine extends AbstractEngine {
2099
- constructor(graphRuntime) {
2100
- super(graphRuntime);
2101
- this.graphRuntime.pause();
2102
- }
2103
- launch() { }
2104
- // Pull API
2105
- async computeNode(nodeId) {
2106
- this.graphRuntime.resume();
2107
- this.graphRuntime.scheduleInputsChanged(nodeId);
2108
- await this.graphRuntime.whenIdle();
2109
- this.graphRuntime.pause();
2110
- }
2111
- }
2112
-
2113
- class HybridEngine extends AbstractEngine {
2114
- constructor(graphRuntime, opts = {}) {
2115
- super(graphRuntime);
2116
- this.opts = opts;
2117
- this.windowStart = 0;
2118
- this.countInWindow = 0;
2119
- this.batching = false;
2120
- this.dirtyNodes = new Set();
2121
- this.windowStart = Date.now();
2122
- }
2123
- updateWindow() {
2124
- const now = Date.now();
2125
- const windowMs = this.opts.windowMs ?? 250;
2126
- if (now - this.windowStart > windowMs) {
2127
- this.windowStart = now;
2128
- this.countInWindow = 0;
2129
- if (this.batching) {
2130
- this.graphRuntime.resume();
2131
- this.batching = false;
2132
- // schedule all dirty nodes accumulated during batching
2133
- const nodes = Array.from(this.dirtyNodes);
2134
- this.dirtyNodes.clear();
2135
- for (const n of nodes)
2136
- this.graphRuntime.scheduleInputsChanged(n);
2137
- if (this.flushTimer) {
2138
- clearTimeout(this.flushTimer);
2139
- this.flushTimer = undefined;
2140
- }
2141
- }
2142
- }
2143
- }
2144
- launch() {
2145
- this.graphRuntime.resume();
2146
- }
2147
- setInputs(nodeId, inputs) {
2148
- this.updateWindow();
2149
- this.countInWindow += 1;
2150
- const threshold = this.opts.batchThreshold ?? 5;
2151
- if (!this.batching && this.countInWindow >= threshold) {
2689
+ this.runMode = runMode;
2690
+ // Update runtime pause/resume state based on new mode
2691
+ if (runMode === "manual") {
2152
2692
  this.graphRuntime.pause();
2153
- this.batching = true;
2154
- // ensure flush even if no more inputs arrive
2155
- const windowMs = this.opts.windowMs ?? 250;
2156
- if (this.flushTimer)
2157
- clearTimeout(this.flushTimer);
2158
- this.flushTimer = setTimeout(() => {
2159
- if (!this.batching)
2160
- return;
2161
- this.graphRuntime.resume();
2162
- this.batching = false;
2163
- const nodes = Array.from(this.dirtyNodes);
2164
- this.dirtyNodes.clear();
2165
- for (const n of nodes)
2166
- this.graphRuntime.scheduleInputsChanged(n);
2167
- this.flushTimer = undefined;
2168
- }, windowMs);
2169
2693
  }
2170
- super.setInputs(nodeId, inputs);
2171
- this.dirtyNodes.add(nodeId);
2172
- if (!this.batching)
2173
- this.graphRuntime.scheduleInputsChanged(nodeId);
2174
- }
2175
- triggerExternal(nodeId, event) {
2176
- super.triggerExternal(nodeId, event);
2177
- this.dirtyNodes.add(nodeId);
2178
- }
2179
- dispose() {
2180
- if (this.flushTimer) {
2181
- clearTimeout(this.flushTimer);
2182
- this.flushTimer = undefined;
2694
+ else {
2695
+ this.graphRuntime.resume();
2183
2696
  }
2184
- super.dispose();
2185
- }
2186
- }
2187
-
2188
- // StepEngine: expose explicit step() to process pending changes once
2189
- class StepEngine extends AbstractEngine {
2190
- constructor(graphRuntime) {
2191
- super(graphRuntime);
2192
- this.dirtyNodes = new Set();
2193
- this.graphRuntime.pause();
2194
- }
2195
- launch() { }
2196
- setInputs(nodeId, inputs) {
2197
- super.setInputs(nodeId, inputs);
2198
- this.dirtyNodes.add(nodeId);
2199
- }
2200
- triggerExternal(nodeId, event) {
2201
- super.triggerExternal(nodeId, event);
2202
- this.dirtyNodes.add(nodeId);
2203
- }
2204
- async step() {
2205
- // resume first so scheduling isn't ignored due to pause
2206
- const nodes = Array.from(this.dirtyNodes);
2207
- this.dirtyNodes.clear();
2208
- this.graphRuntime.resume();
2209
- for (const n of nodes)
2210
- this.graphRuntime.scheduleInputsChanged(n);
2211
- await this.graphRuntime.whenIdle();
2212
- this.graphRuntime.pause();
2213
- }
2214
- }
2215
-
2216
- /**
2217
- * Creates an Engine instance for the given GraphRuntime based on engine configuration.
2218
- * This is the single source of truth for engine creation, used by both local and remote runners.
2219
- */
2220
- function createEngine(runtime, config) {
2221
- const engineKind = config?.engine ?? "push";
2222
- const batched = config?.batched ?? { flushIntervalMs: 0 };
2223
- const hybrid = config?.hybrid ?? { windowMs: 250, batchThreshold: 3 };
2224
- switch (engineKind) {
2225
- case "push":
2226
- return new PushEngine(runtime);
2227
- case "batched":
2228
- return new BatchedEngine(runtime, {
2229
- flushIntervalMs: batched.flushIntervalMs,
2230
- });
2231
- case "pull":
2232
- return new PullEngine(runtime);
2233
- case "hybrid":
2234
- return new HybridEngine(runtime, {
2235
- windowMs: hybrid.windowMs,
2236
- batchThreshold: hybrid.batchThreshold,
2237
- });
2238
- case "step":
2239
- return new StepEngine(runtime);
2240
- default:
2241
- throw new Error(`Unknown engine kind: ${engineKind}`);
2242
2697
  }
2243
2698
  }
2244
2699
 
@@ -2481,6 +2936,48 @@ const isJson = (v) => {
2481
2936
  return Object.values(v).every(isJson);
2482
2937
  return false;
2483
2938
  };
2939
+ // Export operation constants for use in examples and tests
2940
+ const BaseMathOperation = {
2941
+ Add: 0,
2942
+ Subtract: 1,
2943
+ Multiply: 2,
2944
+ Divide: 3,
2945
+ Min: 4,
2946
+ Max: 5,
2947
+ Modulo: 6,
2948
+ Power: 7,
2949
+ Round: 8,
2950
+ Floor: 9,
2951
+ Ceil: 10,
2952
+ Abs: 11,
2953
+ Sum: 12,
2954
+ Avg: 13,
2955
+ MinAll: 14,
2956
+ MaxAll: 15,
2957
+ Sin: 16,
2958
+ Cos: 17,
2959
+ Tan: 18,
2960
+ Asin: 19,
2961
+ Acos: 20,
2962
+ Atan: 21,
2963
+ Sqrt: 22,
2964
+ Exp: 23,
2965
+ Log: 24,
2966
+ };
2967
+ const BaseCompareOperation = {
2968
+ LessThan: 0,
2969
+ LessThanOrEqual: 1,
2970
+ GreaterThan: 2,
2971
+ GreaterThanOrEqual: 3,
2972
+ Equal: 4,
2973
+ NotEqual: 5,
2974
+ };
2975
+ const BaseLogicOperation = {
2976
+ Not: 0,
2977
+ And: 1,
2978
+ Or: 2,
2979
+ Xor: 3,
2980
+ };
2484
2981
  function setupBasicGraphRegistry(id) {
2485
2982
  const registry = new Registry(id);
2486
2983
  registry.categories.register(ComputeCategory);
@@ -2565,33 +3062,6 @@ function setupBasicGraphRegistry(id) {
2565
3062
  return undefined;
2566
3063
  }
2567
3064
  });
2568
- const BaseMathOperation = {
2569
- Add: 0,
2570
- Subtract: 1,
2571
- Multiply: 2,
2572
- Divide: 3,
2573
- Min: 4,
2574
- Max: 5,
2575
- Modulo: 6,
2576
- Power: 7,
2577
- Round: 8,
2578
- Floor: 9,
2579
- Ceil: 10,
2580
- Abs: 11,
2581
- Sum: 12,
2582
- Avg: 13,
2583
- MinAll: 14,
2584
- MaxAll: 15,
2585
- Sin: 16,
2586
- Cos: 17,
2587
- Tan: 18,
2588
- Asin: 19,
2589
- Acos: 20,
2590
- Atan: 21,
2591
- Sqrt: 22,
2592
- Exp: 23,
2593
- Log: 24,
2594
- };
2595
3065
  // Enums: Math Operation
2596
3066
  registry.registerEnum({
2597
3067
  id: "enum:base.math.operation",
@@ -2600,14 +3070,6 @@ function setupBasicGraphRegistry(id) {
2600
3070
  label,
2601
3071
  })),
2602
3072
  });
2603
- const BaseCompareOperation = {
2604
- LessThan: 0,
2605
- LessThanOrEqual: 1,
2606
- GreaterThan: 2,
2607
- GreaterThanOrEqual: 3,
2608
- Equal: 4,
2609
- NotEqual: 5,
2610
- };
2611
3073
  // Enums: Compare Operation
2612
3074
  registry.registerEnum({
2613
3075
  id: "enum:base.compare.operation",
@@ -2616,12 +3078,6 @@ function setupBasicGraphRegistry(id) {
2616
3078
  label,
2617
3079
  })),
2618
3080
  });
2619
- const BaseLogicOperation = {
2620
- Not: 0,
2621
- And: 1,
2622
- Or: 2,
2623
- Xor: 3,
2624
- };
2625
3081
  // Enums: Logic Operation
2626
3082
  registry.registerEnum({
2627
3083
  id: "enum:base.logic.operation",
@@ -3452,7 +3908,8 @@ function installLogging(engine) {
3452
3908
  console.log(`[progress] ${s.runId || s.nodeId}: ${pct}%`);
3453
3909
  }
3454
3910
  else if (s.kind === "node-done") {
3455
- console.log(`[done] ${s.runId || s.nodeId} in ${s.durationMs ?? 0}ms`);
3911
+ const cancelled = s.cancelled ? " (cancelled)" : "";
3912
+ console.log(`[done] ${s.runId || s.nodeId} in ${s.durationMs ?? 0}ms${cancelled}`);
3456
3913
  }
3457
3914
  else if (s.kind === "node-start") {
3458
3915
  console.log(`[start] ${s.runId || s.nodeId}`);
@@ -3471,6 +3928,21 @@ function installLogging(engine) {
3471
3928
  else if (e.kind === "edge-convert") {
3472
3929
  console.warn(`[error] ${e.edgeId} ${e.source.nodeId}.${e.source.handle} -> ${e.target.nodeId}.${e.target.handle}`, e.err?.message ?? e.err);
3473
3930
  }
3931
+ else if (e.kind === "input-validation") {
3932
+ console.warn(`[error] input-validation: ${e.nodeId}.${e.handle} (type ${e.typeId})`, e.message);
3933
+ }
3934
+ else if (e.kind === "registry") {
3935
+ console.warn(`[error] registry:`, e.err?.message ?? e.err, e.attempt !== undefined
3936
+ ? `(attempt ${e.attempt}/${e.maxAttempts ?? "?"})`
3937
+ : "");
3938
+ }
3939
+ else if (e.kind === "system") {
3940
+ console.warn(`[error] system: ${e.message}`, e.code ? `(code: ${e.code})` : "", e.err ? e.err?.message ?? e.err : "", e.details ? JSON.stringify(e.details) : "");
3941
+ }
3942
+ else {
3943
+ // Log any other error kinds (shouldn't happen, but handle gracefully)
3944
+ console.warn(`[error] unknown error kind:`, e);
3945
+ }
3474
3946
  });
3475
3947
  }
3476
3948
 
@@ -3548,7 +4020,6 @@ function createAsyncGraphDef() {
3548
4020
  source: { nodeId: "n4", handle: "XYZ" },
3549
4021
  target: { nodeId: "n1", handle: "A" },
3550
4022
  typeId: "base.vec3[]",
3551
- // convertAsync,
3552
4023
  },
3553
4024
  {
3554
4025
  id: "e3",
@@ -3606,9 +4077,9 @@ function createProgressGraphRegistry(id) {
3606
4077
 
3607
4078
  function createValidationGraphDef() {
3608
4079
  // Intentionally build a graph with validation issues:
3609
- // - Unknown edge type (wire number to boolean input without coercion)
4080
+ // - Unknown node type
3610
4081
  // - Missing target input handle
3611
- // - Multi inbound to same input
4082
+ // - Multi inbound to same input (warning)
3612
4083
  const def = {
3613
4084
  nodes: [
3614
4085
  { nodeId: "nA", typeId: "base.input.number" },
@@ -3616,7 +4087,7 @@ function createValidationGraphDef() {
3616
4087
  { nodeId: "nC", typeId: "base.math" },
3617
4088
  { nodeId: "s1", typeId: "base.object.toString" },
3618
4089
  { nodeId: "cmp", typeId: "base.compare" },
3619
- // Global validation issue: unknown node type (no nodeId/edgeId in data)
4090
+ // Validation issue: unknown node type
3620
4091
  { nodeId: "bad", typeId: "unknownType" },
3621
4092
  ],
3622
4093
  edges: [
@@ -3638,7 +4109,6 @@ function createValidationGraphDef() {
3638
4109
  source: { nodeId: "nB", handle: "Result" },
3639
4110
  target: { nodeId: "nC", handle: "A" },
3640
4111
  },
3641
- // Type mismatch to highlight coercion/validation (string -> float[] should error)
3642
4112
  {
3643
4113
  id: "e4",
3644
4114
  source: { nodeId: "s1", handle: "Text" },
@@ -4304,21 +4774,19 @@ function buildValueConverter(config) {
4304
4774
  };
4305
4775
  }
4306
4776
 
4307
- exports.BatchedEngine = BatchedEngine;
4777
+ exports.BaseCompareOperation = BaseCompareOperation;
4778
+ exports.BaseLogicOperation = BaseLogicOperation;
4779
+ exports.BaseMathOperation = BaseMathOperation;
4308
4780
  exports.CompositeCategory = CompositeCategory;
4309
4781
  exports.ComputeCategory = ComputeCategory;
4310
4782
  exports.GraphBuilder = GraphBuilder;
4311
4783
  exports.GraphRuntime = GraphRuntime;
4312
- exports.HybridEngine = HybridEngine;
4313
- exports.PullEngine = PullEngine;
4314
- exports.PushEngine = PushEngine;
4315
4784
  exports.Registry = Registry;
4316
- exports.StepEngine = StepEngine;
4785
+ exports.UnifiedEngine = UnifiedEngine;
4317
4786
  exports.buildValueConverter = buildValueConverter;
4318
4787
  exports.computeGraphCenter = computeGraphCenter;
4319
4788
  exports.createAsyncGraphDef = createAsyncGraphDef;
4320
4789
  exports.createAsyncGraphRegistry = createAsyncGraphRegistry;
4321
- exports.createEngine = createEngine;
4322
4790
  exports.createProgressGraphDef = createProgressGraphDef;
4323
4791
  exports.createProgressGraphRegistry = createProgressGraphRegistry;
4324
4792
  exports.createSimpleGraphDef = createSimpleGraphDef;