@bian-womp/spark-workbench 0.2.47 → 0.2.49

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 (31) hide show
  1. package/lib/cjs/index.cjs +308 -161
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/misc/Inspector.d.ts.map +1 -1
  4. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  5. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +2 -2
  6. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  7. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  8. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +7 -3
  9. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  10. package/lib/cjs/src/runtime/IGraphRunner.d.ts +6 -14
  11. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  12. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts +4 -3
  13. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  14. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +12 -3
  15. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  16. package/lib/esm/index.js +310 -163
  17. package/lib/esm/index.js.map +1 -1
  18. package/lib/esm/src/misc/Inspector.d.ts.map +1 -1
  19. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  20. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +2 -2
  21. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  22. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  23. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +7 -3
  24. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  25. package/lib/esm/src/runtime/IGraphRunner.d.ts +6 -14
  26. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  27. package/lib/esm/src/runtime/LocalGraphRunner.d.ts +4 -3
  28. package/lib/esm/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  29. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +12 -3
  30. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  31. package/package.json +4 -4
package/lib/esm/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { GraphBuilder, StepEngine, HybridEngine, PullEngine, BatchedEngine, PushEngine, isTypedOutput, getTypedOutputValue, getTypedOutputTypeId, isInputPrivate, getInputTypeId, createSimpleGraphRegistry, createSimpleGraphDef, createAsyncGraphDef, createAsyncGraphRegistry, createProgressGraphDef, createProgressGraphRegistry, createValidationGraphDef, createValidationGraphRegistry } from '@bian-womp/spark-graph';
1
+ import { GraphBuilder, createEngine, StepEngine, PullEngine, BatchedEngine, isTypedOutput, getTypedOutputValue, getTypedOutputTypeId, isInputPrivate, getInputTypeId, createSimpleGraphRegistry, createSimpleGraphDef, createAsyncGraphDef, createAsyncGraphRegistry, createProgressGraphDef, createProgressGraphRegistry, createValidationGraphDef, createValidationGraphRegistry } from '@bian-womp/spark-graph';
2
2
  import { RuntimeApiClient } from '@bian-womp/spark-remote';
3
3
  import { Position, Handle, useUpdateNodeInternals, useReactFlow, ReactFlowProvider, ReactFlow, Background, BackgroundVariant, MiniMap, Controls } from '@xyflow/react';
4
4
  import React, { useCallback, useState, useRef, useEffect, useMemo, createContext, useContext, useImperativeHandle } from 'react';
5
5
  import cx from 'classnames';
6
6
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
7
- import { XCircleIcon, WarningCircleIcon, CopyIcon, TrashIcon, XIcon, ArrowClockwiseIcon, PlugsConnectedIcon, ClockClockwiseIcon, WifiHighIcon, WifiSlashIcon, PlayPauseIcon, LightningIcon, StopIcon, PlayIcon, TreeStructureIcon, CornersOutIcon, DownloadSimpleIcon, DownloadIcon, UploadIcon, BugBeetleIcon, ListBulletsIcon } from '@phosphor-icons/react';
7
+ import { XCircleIcon, WarningCircleIcon, CopyIcon, TrashIcon, XIcon, ArrowClockwiseIcon, ClockClockwiseIcon, StopIcon, PlayIcon, PlugsConnectedIcon, WifiHighIcon, WifiSlashIcon, PlayPauseIcon, LightningIcon, TreeStructureIcon, CornersOutIcon, DownloadSimpleIcon, DownloadIcon, UploadIcon, BugBeetleIcon, ListBulletsIcon } from '@phosphor-icons/react';
8
8
  import isEqual from 'lodash/isEqual';
9
9
 
10
10
  class DefaultUIExtensionRegistry {
@@ -332,10 +332,12 @@ class AbstractGraphRunner {
332
332
  this.backend = backend;
333
333
  this.listeners = new Map();
334
334
  this.stagedInputs = {};
335
+ this.runnerId = "";
335
336
  }
336
337
  launch(def, opts) {
338
+ // Auto-stop if engine is already running
337
339
  if (this.engine) {
338
- throw new Error("Engine already running. Stop the current engine first.");
340
+ this.stop();
339
341
  }
340
342
  }
341
343
  setInput(nodeId, handle, value) {
@@ -387,6 +389,39 @@ class AbstractGraphRunner {
387
389
  async whenIdle() {
388
390
  await this.engine?.whenIdle();
389
391
  }
392
+ stop() {
393
+ if (!this.engine)
394
+ return;
395
+ // Dispose engine (cleans up timers, listeners, etc.)
396
+ this.engine.dispose();
397
+ this.engine = undefined;
398
+ // Emit status but keep runtime alive
399
+ if (this.runningKind) {
400
+ this.runningKind = undefined;
401
+ this.emit("status", { running: false, engine: undefined });
402
+ }
403
+ }
404
+ async switchEngine(opts) {
405
+ if (!this.engine || !this.runtime) {
406
+ throw new Error("No engine running to switch from");
407
+ }
408
+ // Wait for current engine to be idle
409
+ await this.whenIdle();
410
+ // Capture current state
411
+ const currentInputs = { ...this.stagedInputs };
412
+ // Stop current engine
413
+ this.stop();
414
+ // Ensure runtime is in a clean state (resumed)
415
+ this.runtime.resume();
416
+ // Create and launch new engine (to be implemented by subclasses)
417
+ await this.createAndLaunchEngine(opts);
418
+ // Re-apply staged inputs to new engine
419
+ for (const [nodeId, map] of Object.entries(currentInputs)) {
420
+ if (this.engine) {
421
+ this.engine.setInputs(nodeId, map);
422
+ }
423
+ }
424
+ }
390
425
  on(event, handler) {
391
426
  if (!this.listeners.has(event))
392
427
  this.listeners.set(event, new Set());
@@ -418,6 +453,8 @@ class AbstractGraphRunner {
418
453
  }
419
454
  }
420
455
 
456
+ // Counter for generating readable runner IDs
457
+ let localRunnerCounter = 0;
421
458
  class LocalGraphRunner extends AbstractGraphRunner {
422
459
  constructor(registry) {
423
460
  super(registry, { kind: "local" });
@@ -436,7 +473,11 @@ class LocalGraphRunner extends AbstractGraphRunner {
436
473
  this.getEnvironment = () => {
437
474
  return this.runtime?.getEnvironment?.();
438
475
  };
439
- this.emit("transport", { state: "local" });
476
+ // Generate readable ID for this runner instance (e.g., local-001, local-002)
477
+ localRunnerCounter++;
478
+ this.runnerId = `local-${String(localRunnerCounter).padStart(3, "0")}`;
479
+ console.info(`[LocalGraphRunner] Created runner with ID: ${this.runnerId}`);
480
+ this.emit("transport", { runnerId: this.runnerId, state: "local" });
440
481
  }
441
482
  build(def) {
442
483
  const builder = new GraphBuilder(this.registry);
@@ -458,37 +499,30 @@ class LocalGraphRunner extends AbstractGraphRunner {
458
499
  this.build(def);
459
500
  if (!this.runtime)
460
501
  throw new Error("Runtime not built");
461
- const rt = this.runtime;
462
- switch (opts.engine) {
463
- case "push":
464
- this.engine = new PushEngine(rt);
465
- break;
466
- case "batched":
467
- this.engine = new BatchedEngine(rt, {
468
- flushIntervalMs: opts.batched?.flushIntervalMs ?? 0,
469
- });
470
- break;
471
- case "pull":
472
- this.engine = new PullEngine(rt);
473
- break;
474
- case "hybrid":
475
- this.engine = new HybridEngine(rt, {
476
- windowMs: opts.hybrid?.windowMs ?? 250,
477
- batchThreshold: opts.hybrid?.batchThreshold ?? 3,
478
- });
479
- break;
480
- case "step":
481
- this.engine = new StepEngine(rt);
482
- break;
483
- default:
484
- throw new Error("Unknown engine kind");
485
- }
502
+ // Use the async method to create engine
503
+ this.createAndLaunchEngine(opts).catch((err) => {
504
+ console.error("Failed to launch engine:", err);
505
+ const errorMessage = err instanceof Error ? err.message : String(err);
506
+ this.emit("error", {
507
+ kind: "system",
508
+ message: errorMessage,
509
+ err: err instanceof Error ? err : new Error(errorMessage),
510
+ });
511
+ });
512
+ }
513
+ async createAndLaunchEngine(opts) {
514
+ if (!this.runtime)
515
+ throw new Error("Runtime not built");
516
+ // Use shared engine factory
517
+ this.engine = createEngine(this.runtime, opts);
518
+ if (!this.engine)
519
+ throw new Error("Failed to create engine");
486
520
  this.engine.on("value", (e) => this.emit("value", e));
487
521
  this.engine.on("error", (e) => this.emit("error", e));
488
522
  this.engine.on("invalidate", (e) => this.emit("invalidate", e));
489
523
  this.engine.on("stats", (e) => this.emit("stats", e));
490
- this.engine.launch(opts.invalidate);
491
- this.runningKind = opts.engine;
524
+ this.engine.launch(opts?.invalidate);
525
+ this.runningKind = opts?.engine ?? "push";
492
526
  this.emit("status", { running: true, engine: this.runningKind });
493
527
  for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
494
528
  this.engine.setInputs(nodeId, map);
@@ -496,18 +530,18 @@ class LocalGraphRunner extends AbstractGraphRunner {
496
530
  }
497
531
  async step() {
498
532
  const eng = this.engine;
499
- if (eng instanceof StepEngine)
533
+ if (eng && eng instanceof StepEngine)
500
534
  await eng.step();
501
535
  }
502
536
  async computeNode(nodeId) {
503
537
  const eng = this.engine;
504
- if (eng instanceof PullEngine)
538
+ if (eng && eng instanceof PullEngine)
505
539
  await eng.computeNode(nodeId);
506
540
  }
507
- flush() {
541
+ async flush() {
508
542
  const eng = this.engine;
509
- if (eng instanceof BatchedEngine)
510
- eng.flush();
543
+ if (eng && eng instanceof BatchedEngine)
544
+ await eng.flush();
511
545
  }
512
546
  getOutputs(def) {
513
547
  const out = {};
@@ -595,10 +629,12 @@ class LocalGraphRunner extends AbstractGraphRunner {
595
629
  dispose() {
596
630
  super.dispose();
597
631
  this.runtime = undefined;
598
- this.emit("transport", { state: "local" });
632
+ this.emit("transport", { runnerId: this.runnerId, state: "local" });
599
633
  }
600
634
  }
601
635
 
636
+ // Counter for generating readable runner IDs
637
+ let remoteRunnerCounter = 0;
602
638
  class RemoteGraphRunner extends AbstractGraphRunner {
603
639
  /**
604
640
  * Fetch full registry description from remote and register it locally.
@@ -737,13 +773,20 @@ class RemoteGraphRunner extends AbstractGraphRunner {
737
773
  setupClientSubscriptions(client) {
738
774
  // Subscribe to transport status changes
739
775
  // Convert RuntimeApiClient.TransportStatus to IGraphRunner.TransportStatus
776
+ // Only emit status if it matches this runner's ID
740
777
  this.transportStatusUnsubscribe = client.onTransportStatus((status) => {
778
+ if (status.runnerId && status.runnerId !== this.runnerId)
779
+ return;
741
780
  // Map remote-unix to undefined since RemoteGraphRunner doesn't support it
742
781
  const mappedKind = status.kind === "remote-unix" ? undefined : status.kind;
743
- this.emit("transport", {
782
+ const transportStatus = {
744
783
  state: status.state,
745
784
  kind: mappedKind,
746
- });
785
+ runnerId: this.runnerId,
786
+ };
787
+ // Track current status
788
+ this.currentTransportStatus = transportStatus;
789
+ this.emit("transport", transportStatus);
747
790
  });
748
791
  }
749
792
  // Ensure remote client
@@ -764,6 +807,7 @@ class RemoteGraphRunner extends AbstractGraphRunner {
764
807
  // Create client with custom event handler if provided
765
808
  const client = new RuntimeApiClient(clientConfig, {
766
809
  onCustomEvent: backend.onCustomEvent,
810
+ runnerId: this.runnerId,
767
811
  });
768
812
  // Setup event subscriptions
769
813
  this.setupClientSubscriptions(client);
@@ -796,6 +840,15 @@ class RemoteGraphRunner extends AbstractGraphRunner {
796
840
  this.registryFetching = false;
797
841
  this.MAX_REGISTRY_FETCH_ATTEMPTS = 3;
798
842
  this.INITIAL_RETRY_DELAY_MS = 1000; // 1 second
843
+ // Generate readable ID for this runner instance (e.g., remote-001, remote-002)
844
+ remoteRunnerCounter++;
845
+ this.runnerId = `remote-${String(remoteRunnerCounter).padStart(3, "0")}`;
846
+ console.info(`[RemoteGraphRunner] Created runner with ID: ${this.runnerId}`);
847
+ // Initialize transport status as "connecting" - will be updated when connection completes
848
+ this.currentTransportStatus = {
849
+ state: "connecting",
850
+ kind: backend.kind,
851
+ };
799
852
  // Auto-handle registry-changed invalidations from remote
800
853
  // We listen on invalidate and if reason matches, we rehydrate registry and emit a registry event
801
854
  this.ensureClient().then(async (client) => {
@@ -903,30 +956,36 @@ class RemoteGraphRunner extends AbstractGraphRunner {
903
956
  catch {
904
957
  console.error("Failed to hydrate remote inputs/outputs");
905
958
  }
906
- const eng = client.getEngine();
907
- if (!this.listenersBound) {
908
- eng.on("value", (e) => {
909
- this.valueCache.set(`${e.nodeId}.${e.handle}`, {
910
- io: e.io,
911
- value: e.value,
912
- runtimeTypeId: e.runtimeTypeId,
913
- });
914
- this.emit("value", e);
915
- });
916
- eng.on("error", (e) => this.emit("error", e));
917
- eng.on("invalidate", (e) => this.emit("invalidate", e));
918
- eng.on("stats", (e) => this.emit("stats", e));
919
- this.listenersBound = true;
920
- }
921
- this.engine = eng;
922
- this.engine.launch(opts.invalidate);
923
- this.runningKind = "push";
924
- this.emit("status", { running: true, engine: this.runningKind });
925
- for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
926
- this.engine.setInputs(nodeId, map);
927
- }
959
+ await this.createAndLaunchEngine(opts);
928
960
  });
929
961
  }
962
+ async createAndLaunchEngine(opts) {
963
+ const client = await this.ensureClient();
964
+ // Configure and launch engine on the backend
965
+ await client.launch(opts);
966
+ // Get the remote engine proxy and wire up event listeners
967
+ const eng = client.getEngine();
968
+ if (!this.listenersBound) {
969
+ eng.on("value", (e) => {
970
+ this.valueCache.set(`${e.nodeId}.${e.handle}`, {
971
+ io: e.io,
972
+ value: e.value,
973
+ runtimeTypeId: e.runtimeTypeId,
974
+ });
975
+ this.emit("value", e);
976
+ });
977
+ eng.on("error", (e) => this.emit("error", e));
978
+ eng.on("invalidate", (e) => this.emit("invalidate", e));
979
+ eng.on("stats", (e) => this.emit("stats", e));
980
+ this.listenersBound = true;
981
+ }
982
+ this.engine = eng;
983
+ this.runningKind = opts?.engine ?? "push";
984
+ this.emit("status", { running: true, engine: this.runningKind });
985
+ for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
986
+ this.engine.setInputs(nodeId, map);
987
+ }
988
+ }
930
989
  /**
931
990
  * Launch using an existing backend runtime that has already been built and hydrated.
932
991
  * This is used when resuming from a snapshot where the backend has already applied
@@ -942,34 +1001,47 @@ class RemoteGraphRunner extends AbstractGraphRunner {
942
1001
  // has already been built and hydrated via ApplySnapshotFull.
943
1002
  // Calling build() would create a new runtime and lose the restored state.
944
1003
  this.lastDef = def;
945
- // Attach to the existing engine
946
- const eng = client.getEngine();
947
- if (!this.listenersBound) {
948
- eng.on("value", (e) => {
949
- this.valueCache.set(`${e.nodeId}.${e.handle}`, {
950
- io: e.io,
951
- value: e.value,
952
- runtimeTypeId: e.runtimeTypeId,
953
- });
954
- this.emit("value", e);
955
- });
956
- eng.on("error", (e) => this.emit("error", e));
957
- eng.on("invalidate", (e) => this.emit("invalidate", e));
958
- eng.on("stats", (e) => this.emit("stats", e));
959
- this.listenersBound = true;
960
- }
961
- this.engine = eng;
962
- this.engine.launch(opts.invalidate);
963
- this.runningKind = "push";
964
- this.emit("status", { running: true, engine: this.runningKind });
965
- for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
966
- this.engine.setInputs(nodeId, map);
967
- }
1004
+ await this.createAndLaunchEngine(opts);
968
1005
  });
969
1006
  }
970
- async step() { }
971
- async computeNode(nodeId) { }
972
- flush() { }
1007
+ async switchEngine(opts) {
1008
+ if (!this.engine) {
1009
+ throw new Error("No engine running to switch from");
1010
+ }
1011
+ // Wait for current engine to be idle
1012
+ await this.whenIdle();
1013
+ // Capture current state
1014
+ const currentInputs = { ...this.stagedInputs };
1015
+ // For remote runners, we cannot call this.stop() because it sends a Dispose
1016
+ // command that destroys the graphRuntime on the backend. Instead, we rely on
1017
+ // the backend's launch() method to dispose the old engine and create a new one.
1018
+ // Reconfigure engine on the backend (this will dispose old engine and create new one)
1019
+ const client = await this.ensureClient();
1020
+ await client.launch(opts);
1021
+ // Get the remote engine proxy (should be the same RemoteEngine instance)
1022
+ const eng = client.getEngine();
1023
+ // Update local state to reflect new engine kind
1024
+ // Note: The RemoteEngine instance itself doesn't change, but the backend engine does
1025
+ this.engine = eng;
1026
+ this.runningKind = opts?.engine ?? "push";
1027
+ this.emit("status", { running: true, engine: this.runningKind });
1028
+ // Re-apply staged inputs to new engine
1029
+ for (const [nodeId, map] of Object.entries(currentInputs)) {
1030
+ this.engine.setInputs(nodeId, map);
1031
+ }
1032
+ }
1033
+ async step() {
1034
+ const client = await this.ensureClient();
1035
+ await client.step();
1036
+ }
1037
+ async computeNode(nodeId) {
1038
+ const client = await this.ensureClient();
1039
+ await client.computeNode(nodeId);
1040
+ }
1041
+ async flush() {
1042
+ const client = await this.ensureClient();
1043
+ await client.flush();
1044
+ }
973
1045
  triggerExternal(nodeId, event) {
974
1046
  this.ensureClient().then(async (client) => {
975
1047
  try {
@@ -1117,6 +1189,7 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1117
1189
  if (this.disposed)
1118
1190
  return;
1119
1191
  this.disposed = true;
1192
+ console.info(`[RemoteGraphRunner] Disposing runner with ID: ${this.runnerId}`);
1120
1193
  super.dispose();
1121
1194
  // Clear client promise if any
1122
1195
  this.clientPromise = undefined;
@@ -1136,10 +1209,30 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1136
1209
  console.warn("[RemoteGraphRunner] Error disposing client:", err);
1137
1210
  });
1138
1211
  }
1139
- this.emit("transport", {
1212
+ const disconnectedStatus = {
1140
1213
  state: "disconnected",
1141
1214
  kind: this.backend.kind,
1142
- });
1215
+ runnerId: this.runnerId,
1216
+ };
1217
+ this.currentTransportStatus = disconnectedStatus;
1218
+ this.emit("transport", disconnectedStatus);
1219
+ }
1220
+ /**
1221
+ * Override on() to emit current transport status immediately when a new listener subscribes.
1222
+ * This ensures listeners don't miss the current status when they attach after connection.
1223
+ */
1224
+ on(event, handler) {
1225
+ const unsubscribe = super.on(event, handler);
1226
+ // If subscribing to transport events and we have a current status, emit it immediately
1227
+ if (event === "transport" && this.currentTransportStatus) {
1228
+ // Use setTimeout to ensure this happens after the listener is registered
1229
+ // This prevents issues if the handler modifies state synchronously
1230
+ setTimeout(() => {
1231
+ // Type assertion is safe here because we checked event === "transport"
1232
+ handler(this.currentTransportStatus);
1233
+ }, 0);
1234
+ }
1235
+ return unsubscribe;
1143
1236
  }
1144
1237
  }
1145
1238
 
@@ -2325,16 +2418,28 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2325
2418
  offRunnerRegistry();
2326
2419
  offRunnerTransport();
2327
2420
  };
2421
+ }, [runner, wb, setRegistry]);
2422
+ const isRunning = useCallback(() => runner.isRunning(), [runner]);
2423
+ const engineKind = useCallback(() => runner.getRunningEngine(), [runner]);
2424
+ const start = useCallback((engine) => {
2425
+ try {
2426
+ runner.launch(wb.export(), { engine });
2427
+ }
2428
+ catch { }
2328
2429
  }, [runner, wb]);
2430
+ const stop = useCallback(() => runner.stop(), [runner]);
2431
+ const step = useCallback(() => runner.step(), [runner]);
2432
+ const flush = useCallback(() => runner.flush(), [runner]);
2329
2433
  // Push incremental updates into running engine without full reload
2434
+ const isGraphRunning = isRunning();
2330
2435
  useEffect(() => {
2331
- if (runner.isRunning()) {
2436
+ if (isGraphRunning) {
2332
2437
  try {
2333
2438
  runner.update(def);
2334
2439
  }
2335
2440
  catch { }
2336
2441
  }
2337
- }, [runner, def, graphTick]);
2442
+ }, [runner, isGraphRunning, def, graphTick]);
2338
2443
  const validationByNode = useMemo(() => {
2339
2444
  const inputs = {};
2340
2445
  const outputs = {};
@@ -2398,17 +2503,6 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2398
2503
  }
2399
2504
  return { errors, issues };
2400
2505
  }, [validation]);
2401
- const isRunning = useCallback(() => runner.isRunning(), [runner]);
2402
- const engineKind = useCallback(() => runner.getRunningEngine(), [runner]);
2403
- const start = useCallback((engine) => {
2404
- try {
2405
- runner.launch(wb.export(), { engine });
2406
- }
2407
- catch { }
2408
- }, [runner, wb]);
2409
- const stop = useCallback(() => runner.dispose(), [runner]);
2410
- const step = useCallback(() => runner.step(), [runner]);
2411
- const flush = useCallback(() => runner.flush(), [runner]);
2412
2506
  const value = useMemo(() => ({
2413
2507
  wb,
2414
2508
  runner,
@@ -2614,6 +2708,42 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2614
2708
  outputs: nodeValidationHandles?.outputs?.[selectedNodeId] ?? [],
2615
2709
  }
2616
2710
  : { inputs: [], outputs: [] };
2711
+ // Render edge status indicator
2712
+ const renderEdgeStatus = useCallback(() => {
2713
+ if (!selectedEdge)
2714
+ return null;
2715
+ const status = edgeStatus?.[selectedEdge.id];
2716
+ if (status?.activeRuns > 0) {
2717
+ return (jsxs("div", { className: "mt-1 text-xs text-blue-700 bg-blue-50 border border-blue-200 rounded px-2 py-1", children: [jsxs("div", { className: "font-semibold", children: ["Running (", status.activeRuns, ")"] }), jsx("div", { className: "text-[10px] text-blue-600 mt-1", children: "Note: Edge runIds are not available in stats events" })] }));
2718
+ }
2719
+ return null;
2720
+ }, [selectedEdge, edgeStatus]);
2721
+ // Render linked input display value
2722
+ const renderLinkedInputDisplay = useCallback((typeId, current) => {
2723
+ const displayStr = safeToString(typeId, current);
2724
+ const isLong = displayStr.length > 50;
2725
+ const truncated = isLong ? truncateValue(displayStr) : displayStr;
2726
+ return (jsxs("div", { className: "flex items-center gap-1", children: [jsx("span", { className: "truncate", children: truncated }), isLong && (jsx("button", { className: "flex-shrink-0 p-1 hover:bg-gray-100 rounded", onClick: () => copyToClipboard(displayStr), title: "Copy full value", children: jsx(CopyIcon, { size: 14 }) }))] }));
2727
+ }, [safeToString, truncateValue, copyToClipboard]);
2728
+ // Render output validation issues badge
2729
+ const renderOutputValidationBadge = useCallback((handle) => {
2730
+ const outIssues = selectedNodeHandleValidation.outputs.filter((m) => m.handle === handle);
2731
+ if (outIssues.length === 0)
2732
+ return null;
2733
+ const outErr = outIssues.some((m) => m.level === "error");
2734
+ const outTitle = outIssues
2735
+ .map((v) => `${v.code}: ${v.message}`)
2736
+ .join("; ");
2737
+ return (jsx(IssueBadge, { level: outErr ? "error" : "warning", size: 24, className: "ml-1 w-6 h-6", title: outTitle }));
2738
+ }, [selectedNodeHandleValidation]);
2739
+ // Render output display value
2740
+ const renderOutputDisplay = useCallback((outputValue, effectiveHandle) => {
2741
+ const { typeId, value } = resolveOutputDisplay(outputValue, effectiveHandle);
2742
+ const displayStr = safeToString(typeId, value);
2743
+ const isLong = displayStr.length > 50;
2744
+ const truncated = isLong ? truncateValue(displayStr) : displayStr;
2745
+ return (jsxs("div", { className: "flex items-center gap-1", children: [jsx("span", { className: "truncate", children: truncated }), isLong && (jsx("button", { className: "flex-shrink-0 p-1 hover:bg-gray-100 rounded", onClick: () => copyToClipboard(displayStr), title: "Copy full value", children: jsx(CopyIcon, { size: 14 }) }))] }));
2746
+ }, [safeToString, truncateValue, copyToClipboard]);
2617
2747
  // Local drafts and originals for commit-on-blur/enter behavior
2618
2748
  const [drafts, setDrafts] = useState({});
2619
2749
  const [originals, setOriginals] = useState({});
@@ -2671,10 +2801,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2671
2801
  return (jsxs("div", { className: `${widthClass} border-l border-gray-300 p-3 flex flex-col h-full min-h-0 overflow-auto`, children: [jsxs("div", { className: "flex-1 overflow-auto", children: [contextPanel && jsx("div", { className: "mb-2", children: contextPanel }), inputValidationErrors.length > 0 && (jsxs("div", { className: "mb-2 space-y-1", children: [inputValidationErrors.map((err, i) => (jsxs("div", { className: "text-xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 flex items-start justify-between gap-2", children: [jsxs("div", { className: "flex-1", children: [jsx("div", { className: "font-semibold", children: "Input Validation Error" }), jsx("div", { className: "break-words", children: err.message }), jsxs("div", { className: "text-[10px] text-red-600 mt-1", children: [err.nodeId, ".", err.handle, " (type: ", err.typeId, ")"] })] }), jsx("button", { className: "text-red-500 hover:text-red-700 text-[10px] px-1", onClick: () => removeInputValidationError(i), title: "Dismiss", children: jsx(XIcon, { size: 10 }) })] }, i))), inputValidationErrors.length > 1 && (jsx("button", { className: "text-xs text-red-600 hover:text-red-800 underline", onClick: clearInputValidationErrors, children: "Clear all" }))] })), systemErrors.length > 0 && (jsxs("div", { className: "mb-2 space-y-1", children: [systemErrors.map((err, i) => (jsxs("div", { className: "text-xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 flex items-start justify-between gap-2", children: [jsxs("div", { className: "flex-1", children: [jsx("div", { className: "font-semibold", children: err.code ? `Error ${err.code}` : "Error" }), jsx("div", { className: "break-words", children: err.message })] }), jsx("button", { className: "text-red-500 hover:text-red-700 text-[10px] px-1", onClick: () => removeSystemError(i), title: "Dismiss", children: jsx(XIcon, { size: 10 }) })] }, i))), systemErrors.length > 1 && (jsx("button", { className: "text-xs text-red-600 hover:text-red-800 underline", onClick: clearSystemErrors, children: "Clear all" }))] })), registryErrors.length > 0 && (jsxs("div", { className: "mb-2 space-y-1", children: [registryErrors.map((err, i) => (jsxs("div", { className: "text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 flex items-start justify-between gap-2", children: [jsxs("div", { className: "flex-1", children: [jsx("div", { className: "font-semibold", children: "Registry Error" }), jsx("div", { className: "break-words", children: err.message }), err.attempt && err.maxAttempts && (jsxs("div", { className: "text-[10px] text-amber-600 mt-1", children: ["Attempt ", err.attempt, " of ", err.maxAttempts] }))] }), jsx("button", { className: "text-amber-500 hover:text-amber-700 text-[10px] px-1", onClick: () => removeRegistryError(i), title: "Dismiss", children: jsx(XIcon, { size: 10 }) })] }, i))), registryErrors.length > 1 && (jsx("button", { className: "text-xs text-amber-600 hover:text-amber-800 underline", onClick: clearRegistryErrors, children: "Clear all" }))] })), jsx("div", { className: "font-semibold mb-2", children: "Inspector" }), jsxs("div", { className: "text-xs text-gray-500 mb-2", children: ["valuesTick: ", valuesTick] }), jsx("div", { className: "flex-1", children: !selectedNode && !selectedEdge ? (jsxs("div", { children: [jsx("div", { className: "text-gray-500", children: "Select a node or edge." }), globalValidationIssues && globalValidationIssues.length > 0 && (jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsx("ul", { className: "list-disc ml-4", children: globalValidationIssues.map((m, i) => (jsxs("li", { className: "flex items-center gap-1", children: [jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsx("span", { children: `${m.code}: ${m.message}` }), !!m.data?.edgeId && (jsx("button", { className: "ml-2 text-[10px] px-1 py-[2px] border border-red-300 rounded text-red-700 hover:bg-red-50", onClick: (e) => {
2672
2802
  e.stopPropagation();
2673
2803
  deleteEdgeById(m.data?.edgeId);
2674
- }, title: "Delete referenced edge", children: "Delete edge" }))] }, i))) })] }))] })) : selectedEdge ? (jsxs("div", { children: [jsxs("div", { className: "mb-2", children: [jsxs("div", { children: ["Edge: ", selectedEdge.id] }), jsxs("div", { children: [selectedEdge.source.nodeId, ".", selectedEdge.source.handle, " \u2192", " ", selectedEdge.target.nodeId, ".", selectedEdge.target.handle] }), (() => {
2675
- const status = edgeStatus?.[selectedEdge.id];
2676
- return status?.activeRuns > 0 ? (jsxs("div", { className: "mt-1 text-xs text-blue-700 bg-blue-50 border border-blue-200 rounded px-2 py-1", children: [jsxs("div", { className: "font-semibold", children: ["Running (", status.activeRuns, ")"] }), jsx("div", { className: "text-[10px] text-blue-600 mt-1", children: "Note: Edge runIds are not available in stats events" })] })) : null;
2677
- })(), jsx("div", { className: "mt-1", children: jsx("button", { className: "text-xs px-2 py-1 border border-red-300 rounded text-red-700 hover:bg-red-50", onClick: (e) => {
2804
+ }, title: "Delete referenced edge", children: "Delete edge" }))] }, i))) })] }))] })) : selectedEdge ? (jsxs("div", { children: [jsxs("div", { className: "mb-2", children: [jsxs("div", { children: ["Edge: ", selectedEdge.id] }), jsxs("div", { children: [selectedEdge.source.nodeId, ".", selectedEdge.source.handle, " \u2192", " ", selectedEdge.target.nodeId, ".", selectedEdge.target.handle] }), renderEdgeStatus(), jsx("div", { className: "mt-1", children: jsx("button", { className: "text-xs px-2 py-1 border border-red-300 rounded text-red-700 hover:bg-red-50", onClick: (e) => {
2678
2805
  e.stopPropagation();
2679
2806
  deleteEdgeById(selectedEdge.id);
2680
2807
  }, title: "Delete this edge", children: "Delete edge" }) }), jsxs("div", { className: "flex items-center gap-2 mt-1", children: [jsxs("label", { className: "w-20 flex flex-col", children: [jsx("span", { children: "Type" }), jsx("span", { className: "text-gray-500 text-[11px]", children: "DataTypeId" })] }), jsxs("select", { className: "border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full", value: selectedEdge.typeId ?? "", onChange: (e) => {
@@ -2753,14 +2880,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2753
2880
  ? `Default: ${placeholder}`
2754
2881
  : "(select)" }), registry.enums
2755
2882
  .get(typeId)
2756
- ?.options.map((opt) => (jsx("option", { value: String(opt.value), children: opt.label }, opt.value)))] }), hasValue && !isLinked && (jsx("button", { className: "flex-shrink-0 p-1 hover:bg-gray-100 rounded text-gray-500 hover:text-gray-700", onClick: clearInput, title: "Clear input value", children: jsx(XCircleIcon, { size: 16 }) }))] })) : isLinked ? (jsx("div", { className: "flex items-center gap-1 flex-1", children: jsx("div", { className: "flex-1 min-w-0", children: (() => {
2757
- const displayStr = safeToString(typeId, current);
2758
- const isLong = displayStr.length > 50;
2759
- const truncated = isLong
2760
- ? truncateValue(displayStr)
2761
- : displayStr;
2762
- return (jsxs("div", { className: "flex items-center gap-1", children: [jsx("span", { className: "truncate", children: truncated }), isLong && (jsx("button", { className: "flex-shrink-0 p-1 hover:bg-gray-100 rounded", onClick: () => copyToClipboard(displayStr), title: "Copy full value", children: jsx(CopyIcon, { size: 14 }) }))] }));
2763
- })() }) })) : (jsxs("div", { className: "flex items-center gap-1 flex-1", children: [jsx("input", { className: "border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1", placeholder: placeholder
2883
+ ?.options.map((opt) => (jsx("option", { value: String(opt.value), children: opt.label }, opt.value)))] }), hasValue && !isLinked && (jsx("button", { className: "flex-shrink-0 p-1 hover:bg-gray-100 rounded text-gray-500 hover:text-gray-700", onClick: clearInput, title: "Clear input value", children: jsx(XCircleIcon, { size: 16 }) }))] })) : isLinked ? (jsx("div", { className: "flex items-center gap-1 flex-1", children: jsx("div", { className: "flex-1 min-w-0", children: renderLinkedInputDisplay(typeId, current) }) })) : (jsxs("div", { className: "flex items-center gap-1 flex-1", children: [jsx("input", { className: "border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-1", placeholder: placeholder
2764
2884
  ? `Default: ${placeholder}`
2765
2885
  : undefined, value: displayValue, onChange: (e) => onChangeText(e.target.value), onBlur: commit, onKeyDown: (e) => {
2766
2886
  if (e.key === "Enter")
@@ -2768,24 +2888,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2768
2888
  if (e.key === "Escape")
2769
2889
  revert();
2770
2890
  }, ...commonProps }), hasValue && !isLinked && (jsx("button", { className: "flex-shrink-0 p-1 hover:bg-gray-100 rounded text-gray-500 hover:text-gray-700", onClick: clearInput, title: "Clear input value", children: jsx(XCircleIcon, { size: 16 }) }))] }))] }, h));
2771
- }))] }), jsxs("div", { children: [jsx("div", { className: "font-semibold mb-1", children: "Outputs" }), outputHandles.length === 0 ? (jsx("div", { className: "text-gray-500", children: "No outputs" })) : (outputHandles.map((h) => (jsxs("div", { className: "flex items-center gap-2 mb-1", children: [jsxs("label", { className: "w-20 flex flex-col", children: [jsx("span", { children: prettyHandle(h) }), jsx("span", { className: "text-gray-500 text-[11px]", children: outputTypesMap[selectedNodeId]?.[h] ?? "" })] }), jsx("div", { className: "flex-1 min-w-0", children: (() => {
2772
- const { typeId, value } = resolveOutputDisplay(nodeOutputs[h], effectiveHandles.outputs[h]);
2773
- const displayStr = safeToString(typeId, value);
2774
- const isLong = displayStr.length > 50;
2775
- const truncated = isLong
2776
- ? truncateValue(displayStr)
2777
- : displayStr;
2778
- return (jsxs("div", { className: "flex items-center gap-1", children: [jsx("span", { className: "truncate", children: truncated }), isLong && (jsx("button", { className: "flex-shrink-0 p-1 hover:bg-gray-100 rounded", onClick: () => copyToClipboard(displayStr), title: "Copy full value", children: jsx(CopyIcon, { size: 14 }) }))] }));
2779
- })() }), (() => {
2780
- const outIssues = selectedNodeHandleValidation.outputs.filter((m) => m.handle === h);
2781
- if (outIssues.length === 0)
2782
- return null;
2783
- const outErr = outIssues.some((m) => m.level === "error");
2784
- const outTitle = outIssues
2785
- .map((v) => `${v.code}: ${v.message}`)
2786
- .join("; ");
2787
- return (jsx(IssueBadge, { level: outErr ? "error" : "warning", size: 24, className: "ml-1 w-6 h-6", title: outTitle }));
2788
- })()] }, h))))] }), selectedNodeValidation.length > 0 && (jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsx("ul", { className: "list-disc ml-4", children: selectedNodeValidation.map((m, i) => (jsxs("li", { className: "flex items-center gap-1", children: [jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsx("span", { children: `${m.code}: ${m.message}` }), !!m.data?.edgeId && (jsx("button", { className: "ml-2 text-[10px] px-1 py-[2px] border border-red-300 rounded text-red-700 hover:bg-red-50", onClick: (e) => {
2891
+ }))] }), jsxs("div", { children: [jsx("div", { className: "font-semibold mb-1", children: "Outputs" }), outputHandles.length === 0 ? (jsx("div", { className: "text-gray-500", children: "No outputs" })) : (outputHandles.map((h) => (jsxs("div", { className: "flex items-center gap-2 mb-1", children: [jsxs("label", { className: "w-20 flex flex-col", children: [jsx("span", { children: prettyHandle(h) }), jsx("span", { className: "text-gray-500 text-[11px]", children: outputTypesMap[selectedNodeId]?.[h] ?? "" })] }), jsx("div", { className: "flex-1 min-w-0", children: renderOutputDisplay(nodeOutputs[h], effectiveHandles.outputs[h]) }), renderOutputValidationBadge(h)] }, h))))] }), selectedNodeValidation.length > 0 && (jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsx("ul", { className: "list-disc ml-4", children: selectedNodeValidation.map((m, i) => (jsxs("li", { className: "flex items-center gap-1", children: [jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsx("span", { children: `${m.code}: ${m.message}` }), !!m.data?.edgeId && (jsx("button", { className: "ml-2 text-[10px] px-1 py-[2px] border border-red-300 rounded text-red-700 hover:bg-red-50", onClick: (e) => {
2789
2892
  e.stopPropagation();
2790
2893
  deleteEdgeById(m.data?.edgeId);
2791
2894
  }, title: "Delete referenced edge", children: "Delete edge" }))] }, i))) })] }))] })) })] }), debug && (jsx("div", { className: "mt-3 flex-none min-h-0 h-[50%]", children: jsx(DebugEvents, { autoScroll: !!autoScroll, hideWorkbench: !!hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange }) }))] }));
@@ -3540,6 +3643,44 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3540
3643
  ? computeEffectiveHandles(selectedNode, registry)
3541
3644
  : { inputs: {}, outputs: {}, inputDefaults: {} };
3542
3645
  const [exampleState, setExampleState] = useState(example ?? "");
3646
+ const isGraphRunning = runner.isRunning();
3647
+ const engineKind = runner.getRunningEngine();
3648
+ // Render Start/Stop button based on transport and runner state
3649
+ const renderStartStopButton = useCallback(() => {
3650
+ // Check if transport is connecting/retrying
3651
+ const isConnecting = transportStatus.state === "connecting" ||
3652
+ transportStatus.state === "retrying";
3653
+ // Only allow Start/Stop when transport is connected or local
3654
+ const canControl = transportStatus.state === "connected" ||
3655
+ transportStatus.state === "local";
3656
+ if (isConnecting) {
3657
+ return (jsxs("button", { className: "border rounded px-2 py-1.5 text-gray-500 border-gray-400 flex items-center gap-1 disabled:opacity-50", disabled: true, title: "Connecting to backend...", children: [jsx(ClockClockwiseIcon, { size: 16, className: "animate-spin" }), jsx("span", { className: "font-medium ml-1", children: "Connecting..." })] }));
3658
+ }
3659
+ if (isGraphRunning) {
3660
+ return (jsxs("button", { className: "border rounded px-2 py-1.5 text-red-700 border-red-600 flex items-center gap-1 disabled:opacity-50 disabled:text-gray-400 disabled:border-gray-300", onClick: () => runner.stop(), disabled: !canControl, title: canControl ? "Stop engine" : "Waiting for connection", children: [jsx(StopIcon, { size: 16, weight: "fill" }), jsx("span", { className: "font-medium ml-1", children: "Stop" })] }));
3661
+ }
3662
+ return (jsxs("button", { className: "border rounded px-2 py-1.5 text-green-700 border-green-600 flex items-center gap-1 disabled:text-gray-400 disabled:border-gray-300 disabled:opacity-50", onClick: (evt) => {
3663
+ const kind = engine;
3664
+ if (!kind)
3665
+ return alert("Select an engine first.");
3666
+ if (evt.shiftKey && !confirm("Invalidate and re-run graph?"))
3667
+ return;
3668
+ try {
3669
+ runner.launch(wb.export(), {
3670
+ engine: kind,
3671
+ invalidate: evt.shiftKey,
3672
+ });
3673
+ }
3674
+ catch (err) {
3675
+ const message = err instanceof Error ? err.message : String(err);
3676
+ alert(message);
3677
+ }
3678
+ }, disabled: !engine || !canControl, title: !engine
3679
+ ? "Select an engine first"
3680
+ : !canControl
3681
+ ? "Waiting for connection"
3682
+ : "Start engine", children: [jsx(PlayIcon, { size: 16, weight: "fill" }), jsx("span", { className: "font-medium ml-1", children: "Start" })] }));
3683
+ }, [transportStatus, isGraphRunning, runner, engine, wb]);
3543
3684
  const defaultExamples = useMemo(() => [
3544
3685
  {
3545
3686
  id: "simple",
@@ -3588,7 +3729,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3588
3729
  return backendKind === "local";
3589
3730
  });
3590
3731
  // Expose init callback with setInitialGraph helper
3591
- // Note: This runs whenever runner changes (e.g., when Flow is enabled and backendOptions changes)
3732
+ // Note: This runs whenever runner changes
3592
3733
  useEffect(() => {
3593
3734
  if (!onInit)
3594
3735
  return;
@@ -3764,7 +3905,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3764
3905
  applyExample(example);
3765
3906
  }, [example, wb]);
3766
3907
  useEffect(() => {
3767
- const off = runner.on("transport", (s) => setTransportStatus(s));
3908
+ const off = runner.on("transport", setTransportStatus);
3768
3909
  return () => off();
3769
3910
  }, [runner]);
3770
3911
  // Track registry readiness for remote backends
@@ -3785,7 +3926,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3785
3926
  useEffect(() => {
3786
3927
  if (!engine)
3787
3928
  return;
3788
- if (runner.isRunning())
3929
+ if (isGraphRunning)
3789
3930
  return;
3790
3931
  // Only auto-launch for local backend; require explicit Start for remote
3791
3932
  if (backendKind !== "local")
@@ -3802,7 +3943,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3802
3943
  catch {
3803
3944
  // ignore
3804
3945
  }
3805
- }, [engine, runner, wb, backendKind]);
3946
+ }, [engine, runner, isGraphRunning, wb, backendKind]);
3806
3947
  // Registry is automatically fetched by RemoteGraphRunner when it connects
3807
3948
  // Run auto layout after registry is hydrated (for remote backends)
3808
3949
  useEffect(() => {
@@ -3988,30 +4129,34 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3988
4129
  return overrides.toElement(baseToElement, { registry });
3989
4130
  return baseToElement;
3990
4131
  }, [overrides, baseToElement, registry]);
3991
- return (jsxs("div", { className: "w-full h-screen flex flex-col", children: [jsxs("div", { className: "p-2 border-b border-gray-300 flex gap-2 items-center", children: [runner.isRunning() ? (jsxs("span", { className: "ml-2 text-sm text-green-700", children: ["Running: ", runner.getRunningEngine()] })) : (jsx("span", { className: "ml-2 text-sm text-gray-500", children: "Stopped" })), jsxs("span", { className: "ml-2 flex items-center gap-1 text-xs", title: transportStatus.kind || undefined, children: [transportStatus.state === "local" && (jsx(PlugsConnectedIcon, { size: 14, className: "text-gray-500" })), transportStatus.state === "connecting" && (jsx(ClockClockwiseIcon, { size: 14, className: "text-amber-600 animate-pulse" })), transportStatus.state === "connected" && (jsx(WifiHighIcon, { size: 14, className: "text-green-600" })), transportStatus.state === "disconnected" && (jsx(WifiSlashIcon, { size: 14, className: "text-red-600" })), transportStatus.state === "retrying" && (jsx(ClockClockwiseIcon, { size: 14, className: "text-amber-700 animate-pulse" }))] }), jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: exampleState, onChange: (e) => applyExample(e.target.value), disabled: runner.isRunning(), title: runner.isRunning()
3992
- ? "Stop engine before switching example"
3993
- : undefined, children: [jsx("option", { value: "", children: "Select Example\u2026" }), examples.map((ex) => (jsx("option", { value: ex.id, children: ex.label }, ex.id)))] }), jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: backendKind, onChange: (e) => onBackendKindChange(e.target.value), disabled: runner.isRunning(), title: runner.isRunning()
3994
- ? "Stop engine before switching backend"
3995
- : undefined, children: [jsx("option", { value: "local", children: "Local" }), jsx("option", { value: "remote-http", children: "Remote (HTTP)" }), jsx("option", { value: "remote-ws", children: "Remote (WebSocket)" })] }), backendKind === "remote-http" && !!onHttpBaseUrlChange && (jsx("input", { className: "ml-2 border border-gray-300 rounded px-2 py-1 w-72", placeholder: "http://127.0.0.1:18080", value: httpBaseUrl, onChange: (e) => onHttpBaseUrlChange(e.target.value) })), backendKind === "remote-ws" && !!onWsUrlChange && (jsx("input", { className: "ml-2 border border-gray-300 rounded px-2 py-1 w-72", placeholder: "ws://127.0.0.1:18081", value: wsUrl, onChange: (e) => onWsUrlChange(e.target.value) })), jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: runner.getRunningEngine() ?? engine ?? "", onChange: (e) => {
4132
+ return (jsxs("div", { className: "w-full h-screen flex flex-col", children: [jsxs("div", { className: "p-2 border-b border-gray-300 flex gap-2 items-center", children: [isGraphRunning ? (jsxs("span", { className: "ml-2 text-sm text-green-700", children: ["Running: ", engineKind] })) : (jsx("span", { className: "ml-2 text-sm text-gray-500", children: "Stopped" })), jsxs("span", { className: "ml-2 flex items-center gap-1 text-xs", title: transportStatus.kind || undefined, children: [transportStatus.state === "local" && (jsx(PlugsConnectedIcon, { size: 14, className: "text-gray-500" })), transportStatus.state === "connecting" && (jsx(ClockClockwiseIcon, { size: 14, className: "text-amber-600 animate-pulse" })), transportStatus.state === "connected" && (jsx(WifiHighIcon, { size: 14, className: "text-green-600" })), transportStatus.state === "disconnected" && (jsx(WifiSlashIcon, { size: 14, className: "text-red-600" })), transportStatus.state === "retrying" && (jsx(ClockClockwiseIcon, { size: 14, className: "text-amber-700 animate-pulse" }))] }), jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: exampleState, onChange: (e) => applyExample(e.target.value), disabled: isGraphRunning, title: isGraphRunning ? "Stop engine before switching example" : undefined, children: [jsx("option", { value: "", children: "Select Example\u2026" }), examples.map((ex) => (jsx("option", { value: ex.id, children: ex.label }, ex.id)))] }), jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: backendKind, onChange: (e) => onBackendKindChange(e.target.value), disabled: isGraphRunning, title: isGraphRunning ? "Stop engine before switching backend" : undefined, children: [jsx("option", { value: "local", children: "Local" }), jsx("option", { value: "remote-http", children: "Remote (HTTP)" }), jsx("option", { value: "remote-ws", children: "Remote (WebSocket)" })] }), backendKind === "remote-http" && !!onHttpBaseUrlChange && (jsx("input", { className: "border border-gray-300 rounded px-2 py-1 w-72", placeholder: "http://127.0.0.1:18080", value: httpBaseUrl, onChange: (e) => onHttpBaseUrlChange(e.target.value) })), backendKind === "remote-ws" && !!onWsUrlChange && (jsx("input", { className: "border border-gray-300 rounded px-2 py-1 w-72", placeholder: "ws://127.0.0.1:18081", value: wsUrl, onChange: (e) => onWsUrlChange(e.target.value) })), jsxs("select", { className: "border border-gray-300 rounded px-2 py-1", value: engineKind ?? engine ?? "", onChange: async (e) => {
3996
4133
  const kind = e.target.value || undefined;
3997
- onEngineChange?.(kind);
3998
- }, children: [jsx("option", { value: "", children: "Select Engine\u2026" }), jsx("option", { value: "push", children: "Push" }), jsx("option", { value: "batched", children: "Batched" }), jsx("option", { value: "pull", children: "Pull" }), jsx("option", { value: "hybrid", children: "Hybrid" }), jsx("option", { value: "step", children: "Step" })] }), runner.getRunningEngine() === "step" && (jsx("button", { className: "ml-2 border border-gray-300 rounded p-1", onClick: () => runner.step(), disabled: !runner.isRunning(), title: "Step", children: jsx(PlayPauseIcon, { size: 24 }) })), runner.getRunningEngine() === "batched" && (jsx("button", { className: "ml-2 border border-gray-300 rounded p-1", onClick: () => runner.flush(), disabled: !runner.isRunning(), title: "Flush", children: jsx(LightningIcon, { size: 24 }) })), runner.isRunning() ? (jsxs("button", { className: "border rounded px-2 py-1.5 text-red-700 border-red-600 flex items-center gap-1", onClick: () => runner.dispose(), title: "Stop engine", children: [jsx(StopIcon, { size: 16, weight: "fill" }), jsx("span", { className: "font-medium ml-1", children: "Stop" })] })) : (jsxs("button", { className: "border rounded px-2 py-1.5 text-green-700 border-green-600 flex items-center gap-1 disabled:text-gray-400 disabled:border-gray-300", onClick: (evt) => {
3999
- const kind = engine;
4000
- if (!kind)
4001
- return alert("Select an engine first.");
4002
- if (evt.shiftKey && !confirm("Invalidate and re-run graph?"))
4003
- return;
4004
- try {
4005
- runner.launch(wb.export(), {
4006
- engine: kind,
4007
- invalidate: evt.shiftKey,
4008
- });
4134
+ const currentEngine = runner.getRunningEngine();
4135
+ // If engine is running and user selected a different engine, switch it
4136
+ if (runner.isRunning() &&
4137
+ currentEngine &&
4138
+ kind &&
4139
+ kind !== currentEngine) {
4140
+ try {
4141
+ await runner.switchEngine({
4142
+ engine: kind,
4143
+ batched: { flushIntervalMs: 0 },
4144
+ hybrid: { windowMs: 250, batchThreshold: 3 },
4145
+ });
4146
+ onEngineChange?.(kind);
4147
+ }
4148
+ catch (err) {
4149
+ const message = err instanceof Error ? err.message : String(err);
4150
+ alert(`Failed to switch engine: ${message}`);
4151
+ // Reset dropdown to current engine
4152
+ e.target.value = currentEngine;
4153
+ }
4009
4154
  }
4010
- catch (err) {
4011
- const message = err instanceof Error ? err.message : String(err);
4012
- alert(message);
4155
+ else {
4156
+ // Normal change when not running
4157
+ onEngineChange?.(kind);
4013
4158
  }
4014
- }, disabled: !engine, title: engine ? "Start engine" : "Select an engine first", children: [jsx(PlayIcon, { size: 16, weight: "fill" }), jsx("span", { className: "font-medium ml-1", children: "Start" })] })), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsx(TreeStructureIcon, { size: 24 }) }), jsx("button", { className: "ml-2 border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsx(CornersOutIcon, { size: 24 }) }), jsx("button", { className: "ml-2 border border-gray-300 rounded p-1", onClick: downloadGraph, children: jsx(DownloadSimpleIcon, { size: 24 }) }), jsx("button", { className: "ml-2 border border-gray-300 rounded p-1", onClick: async () => {
4159
+ }, children: [jsx("option", { value: "", children: "Select Engine\u2026" }), jsx("option", { value: "push", children: "Push" }), jsx("option", { value: "batched", children: "Batched" }), jsx("option", { value: "pull", children: "Pull" }), jsx("option", { value: "hybrid", children: "Hybrid" }), jsx("option", { value: "step", children: "Step" })] }), engineKind === "step" && (jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.step(), disabled: !isGraphRunning, title: "Step", children: jsx(PlayPauseIcon, { size: 24 }) })), engineKind === "batched" && (jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.flush(), disabled: !isGraphRunning, title: "Flush", children: jsx(LightningIcon, { size: 24 }) })), renderStartStopButton(), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsx(TreeStructureIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsx(CornersOutIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: downloadGraph, children: jsx(DownloadSimpleIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: async () => {
4015
4160
  try {
4016
4161
  const def = wb.export();
4017
4162
  const positions = wb.getPositions();
@@ -4040,7 +4185,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
4040
4185
  const message = err instanceof Error ? err.message : String(err);
4041
4186
  alert(message);
4042
4187
  }
4043
- }, children: jsx(DownloadIcon, { size: 24 }) }), jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsx("button", { className: "ml-2 border border-gray-300 rounded px-2 py-1.5", onClick: triggerUpload, children: jsx(UploadIcon, { size: 24 }) }), jsxs("label", { className: "ml-2 flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsx(BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxs("label", { className: "ml-2 flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsx(ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxs("div", { className: "flex flex-1 min-h-0", children: [jsx("div", { className: "flex-1 min-w-0", children: jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
4188
+ }, children: jsx(DownloadIcon, { size: 24 }) }), jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsx(UploadIcon, { size: 24 }) }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsx(BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsx(ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxs("div", { className: "flex flex-1 min-h-0", children: [jsx("div", { className: "flex-1 min-w-0", children: jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
4044
4189
  }
4045
4190
  function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, backendOptions, overrides, onInit, onChange, }) {
4046
4191
  const [registry, setRegistry] = useState(createSimpleGraphRegistry());
@@ -4104,6 +4249,7 @@ function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, bac
4104
4249
  }
4105
4250
  };
4106
4251
  }, [runner]);
4252
+ const isGraphRunning = runner.isRunning();
4107
4253
  // Track UI registration version to trigger nodeTypes recomputation
4108
4254
  const [uiVersion, setUiVersion] = useState(0);
4109
4255
  // Allow external UI registration (e.g., node renderers) with access to wb
@@ -4114,11 +4260,12 @@ function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, bac
4114
4260
  setUiVersion((v) => v + 1);
4115
4261
  // eslint-disable-next-line react-hooks/exhaustive-deps
4116
4262
  }, [wb, runner, overrides]);
4117
- return (jsx(WorkbenchProvider, { wb: wb, runner: runner, registry: registry, setRegistry: setRegistry, overrides: overrides, uiVersion: uiVersion, children: jsx(WorkbenchStudioCanvas, { setRegistry: setRegistry, autoScroll: autoScroll, onAutoScrollChange: onAutoScrollChange, example: example, onExampleChange: onExampleChange, engine: engine, onEngineChange: onEngineChange, backendKind: backendKind, onBackendKindChange: (v) => {
4118
- if (runner.isRunning())
4119
- runner.dispose();
4120
- onBackendKindChange(v);
4121
- }, httpBaseUrl: httpBaseUrl, onHttpBaseUrlChange: onHttpBaseUrlChange, wsUrl: wsUrl, onWsUrlChange: onWsUrlChange, debug: debug, onDebugChange: onDebugChange, showValues: showValues, onShowValuesChange: onShowValuesChange, hideWorkbench: hideWorkbench, onHideWorkbenchChange: onHideWorkbenchChange, backendOptions: backendOptions, overrides: overrides, onInit: onInit, onChange: onChange }) }));
4263
+ const onBackendKindChangeWithDispose = useCallback(() => (v) => {
4264
+ if (isGraphRunning)
4265
+ runner.dispose();
4266
+ onBackendKindChange(v);
4267
+ }, [isGraphRunning]);
4268
+ return (jsx(WorkbenchProvider, { wb: wb, runner: runner, registry: registry, setRegistry: setRegistry, overrides: overrides, uiVersion: uiVersion, children: jsx(WorkbenchStudioCanvas, { setRegistry: setRegistry, autoScroll: autoScroll, onAutoScrollChange: onAutoScrollChange, example: example, onExampleChange: onExampleChange, engine: engine, onEngineChange: onEngineChange, backendKind: backendKind, onBackendKindChange: onBackendKindChangeWithDispose, httpBaseUrl: httpBaseUrl, onHttpBaseUrlChange: onHttpBaseUrlChange, wsUrl: wsUrl, onWsUrlChange: onWsUrlChange, debug: debug, onDebugChange: onDebugChange, showValues: showValues, onShowValuesChange: onShowValuesChange, hideWorkbench: hideWorkbench, onHideWorkbenchChange: onHideWorkbenchChange, backendOptions: backendOptions, overrides: overrides, onInit: onInit, onChange: onChange }) }));
4122
4269
  }
4123
4270
 
4124
4271
  export { AbstractWorkbench, CLIWorkbench, DefaultNode, DefaultNodeContent, DefaultNodeHeader, DefaultUIExtensionRegistry, InMemoryWorkbench, Inspector, LocalGraphRunner, NodeHandles, RemoteGraphRunner, WorkbenchCanvas, WorkbenchContext, WorkbenchProvider, WorkbenchStudio, computeEffectiveHandles, countVisibleHandles, estimateNodeSize, formatDataUrlAsLabel, formatDeclaredTypeSignature, getHandleClassName, getNodeBorderClassNames, layoutNode, preformatValueForDisplay, prettyHandle, resolveOutputDisplay, summarizeDeep, toReactFlow, useQueryParamBoolean, useQueryParamString, useThrottledValue, useWorkbenchBridge, useWorkbenchContext, useWorkbenchGraphTick, useWorkbenchGraphUiTick, useWorkbenchVersionTick };