@bian-womp/spark-graph 0.2.93 → 0.3.0

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