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