@bian-womp/spark-workbench 0.2.37 → 0.2.38

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.
package/lib/esm/index.js CHANGED
@@ -1,10 +1,10 @@
1
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';
2
- import { HttpPollingTransport, WebSocketTransport, RemoteRunner } from '@bian-womp/spark-remote';
2
+ import { RuntimeApiClient } from '@bian-womp/spark-remote';
3
3
  import React, { useCallback, useState, useRef, useEffect, useMemo, createContext, useContext, useImperativeHandle } from 'react';
4
4
  import { Position, Handle, useUpdateNodeInternals, useReactFlow, ReactFlowProvider, ReactFlow, Background, BackgroundVariant, MiniMap, Controls } from '@xyflow/react';
5
+ import cx from 'classnames';
5
6
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
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 cx from 'classnames';
8
8
  import isEqual from 'lodash/isEqual';
9
9
 
10
10
  class DefaultUIExtensionRegistry {
@@ -600,8 +600,200 @@ class LocalGraphRunner extends AbstractGraphRunner {
600
600
  }
601
601
 
602
602
  class RemoteGraphRunner extends AbstractGraphRunner {
603
+ /**
604
+ * Fetch full registry description from remote and register it locally.
605
+ * Called automatically on first connection with retry mechanism.
606
+ */
607
+ async fetchRegistry(client, attempt = 1) {
608
+ if (this.registryFetching) {
609
+ // Already fetching, don't start another fetch
610
+ return;
611
+ }
612
+ this.registryFetching = true;
613
+ try {
614
+ const desc = await client.describeRegistry();
615
+ // Register types
616
+ for (const t of desc.types) {
617
+ if (t.options) {
618
+ this.registry.registerEnum({
619
+ id: t.id,
620
+ options: t.options,
621
+ bakeTarget: t.bakeTarget,
622
+ });
623
+ }
624
+ else {
625
+ if (!this.registry.types.has(t.id)) {
626
+ this.registry.registerType({
627
+ id: t.id,
628
+ displayName: t.displayName,
629
+ validate: (_v) => true,
630
+ bakeTarget: t.bakeTarget,
631
+ });
632
+ }
633
+ }
634
+ }
635
+ // Register categories
636
+ for (const c of desc.categories || []) {
637
+ if (!this.registry.categories.has(c.id)) {
638
+ // Create placeholder category descriptor
639
+ const category = {
640
+ id: c.id,
641
+ displayName: c.displayName,
642
+ createRuntime: () => ({
643
+ async onInputsChanged() { },
644
+ }),
645
+ policy: { asyncConcurrency: "switch" },
646
+ };
647
+ this.registry.categories.register(category);
648
+ }
649
+ }
650
+ // Register coercions
651
+ for (const c of desc.coercions) {
652
+ if (c.async) {
653
+ this.registry.registerAsyncCoercion(c.from, c.to, async (v) => v, {
654
+ nonTransitive: c.nonTransitive,
655
+ });
656
+ }
657
+ else {
658
+ this.registry.registerCoercion(c.from, c.to, (v) => v, {
659
+ nonTransitive: c.nonTransitive,
660
+ });
661
+ }
662
+ }
663
+ // Register nodes
664
+ for (const n of desc.nodes) {
665
+ if (!this.registry.nodes.has(n.id)) {
666
+ this.registry.registerNode({
667
+ id: n.id,
668
+ categoryId: n.categoryId,
669
+ displayName: n.displayName,
670
+ inputs: n.inputs || {},
671
+ outputs: n.outputs || {},
672
+ impl: () => { },
673
+ });
674
+ }
675
+ }
676
+ this.registryFetched = true;
677
+ this.registryFetching = false;
678
+ this.emit("registry", this.registry);
679
+ }
680
+ catch (err) {
681
+ this.registryFetching = false;
682
+ const error = err instanceof Error ? err : new Error(String(err));
683
+ // Retry with exponential backoff if attempts remaining
684
+ if (attempt < this.MAX_REGISTRY_FETCH_ATTEMPTS) {
685
+ const delayMs = this.INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
686
+ console.warn(`Failed to fetch registry (attempt ${attempt}/${this.MAX_REGISTRY_FETCH_ATTEMPTS}), retrying in ${delayMs}ms...`, error);
687
+ // Emit error event for UI feedback
688
+ this.emit("error", {
689
+ kind: "registry",
690
+ message: `Registry fetch failed (attempt ${attempt}/${this.MAX_REGISTRY_FETCH_ATTEMPTS}), retrying...`,
691
+ err: error,
692
+ attempt,
693
+ maxAttempts: this.MAX_REGISTRY_FETCH_ATTEMPTS,
694
+ });
695
+ // Retry after delay
696
+ setTimeout(() => {
697
+ this.fetchRegistry(client, attempt + 1).catch(() => {
698
+ // Final failure handled below
699
+ });
700
+ }, delayMs);
701
+ }
702
+ else {
703
+ // Max attempts reached, emit final error
704
+ console.error(`Failed to fetch registry after ${this.MAX_REGISTRY_FETCH_ATTEMPTS} attempts:`, error);
705
+ this.emit("error", {
706
+ kind: "registry",
707
+ message: `Failed to fetch registry after ${this.MAX_REGISTRY_FETCH_ATTEMPTS} attempts. Please check your connection and try refreshing.`,
708
+ err: error,
709
+ attempt: this.MAX_REGISTRY_FETCH_ATTEMPTS,
710
+ maxAttempts: this.MAX_REGISTRY_FETCH_ATTEMPTS,
711
+ });
712
+ }
713
+ }
714
+ }
715
+ /**
716
+ * Build RuntimeApiClient config from RemoteExecutionBackend config.
717
+ */
718
+ buildClientConfig(backend) {
719
+ if (backend.kind === "remote-http") {
720
+ return {
721
+ kind: "remote-http",
722
+ baseUrl: backend.baseUrl,
723
+ connectOptions: backend.connectOptions,
724
+ };
725
+ }
726
+ else {
727
+ return {
728
+ kind: "remote-ws",
729
+ url: backend.url,
730
+ connectOptions: backend.connectOptions,
731
+ };
732
+ }
733
+ }
734
+ /**
735
+ * Setup event subscriptions for the client.
736
+ */
737
+ setupClientSubscriptions(client, backend) {
738
+ // Subscribe to custom events (for additional listeners if needed)
739
+ if (backend.onCustomEvent) {
740
+ this.customEventUnsubscribe = client.subscribeCustomEvents(backend.onCustomEvent);
741
+ }
742
+ // Subscribe to transport status changes
743
+ // Convert RuntimeApiClient.TransportStatus to IGraphRunner.TransportStatus
744
+ this.transportStatusUnsubscribe = client.onTransportStatus((status) => {
745
+ // Map remote-unix to undefined since RemoteGraphRunner doesn't support it
746
+ const mappedKind = status.kind === "remote-unix" ? undefined : status.kind;
747
+ this.emit("transport", {
748
+ state: status.state,
749
+ kind: mappedKind,
750
+ });
751
+ });
752
+ }
753
+ // Ensure remote client
754
+ async ensureClient() {
755
+ if (this.disposed) {
756
+ throw new Error("Cannot ensure client: RemoteGraphRunner has been disposed");
757
+ }
758
+ if (this.client)
759
+ return this.client;
760
+ // If already connecting, wait for that connection to complete
761
+ if (this.clientPromise)
762
+ return this.clientPromise;
763
+ const backend = this.backend;
764
+ // Create connection promise to prevent concurrent connections
765
+ this.clientPromise = (async () => {
766
+ // Build client config from backend config
767
+ const clientConfig = this.buildClientConfig(backend);
768
+ // Create client with custom event handler if provided
769
+ const client = new RuntimeApiClient(clientConfig, {
770
+ onCustomEvent: backend.onCustomEvent,
771
+ });
772
+ // Setup event subscriptions
773
+ this.setupClientSubscriptions(client, backend);
774
+ // Connect the client (this will create and connect transport)
775
+ await client.connect();
776
+ this.client = client;
777
+ this.valueCache.clear();
778
+ this.listenersBound = false;
779
+ // Auto-fetch registry on first connection (only once)
780
+ if (!this.registryFetched && !this.registryFetching) {
781
+ // Log loading state (UI can listen to transport status for loading indication)
782
+ console.info("Loading registry from remote...");
783
+ this.fetchRegistry(client).catch((err) => {
784
+ console.error("[RemoteGraphRunner] Failed to fetch registry:", err);
785
+ // Error handling is done inside fetchRegistry, but we catch unhandled rejections
786
+ });
787
+ }
788
+ // Clear promise on success
789
+ this.clientPromise = undefined;
790
+ return client;
791
+ })();
792
+ return this.clientPromise;
793
+ }
603
794
  constructor(registry, backend) {
604
795
  super(registry, backend);
796
+ this.disposed = false;
605
797
  this.valueCache = new Map();
606
798
  this.listenersBound = false;
607
799
  this.registryFetched = false;
@@ -610,8 +802,8 @@ class RemoteGraphRunner extends AbstractGraphRunner {
610
802
  this.INITIAL_RETRY_DELAY_MS = 1000; // 1 second
611
803
  // Auto-handle registry-changed invalidations from remote
612
804
  // We listen on invalidate and if reason matches, we rehydrate registry and emit a registry event
613
- this.ensureRemoteRunner().then(async (runner) => {
614
- const eng = runner.getEngine();
805
+ this.ensureClient().then(async (client) => {
806
+ const eng = client.getEngine();
615
807
  if (!this.listenersBound) {
616
808
  eng.on("invalidate", async (e) => {
617
809
  if (e.reason === "registry-changed") {
@@ -675,9 +867,9 @@ class RemoteGraphRunner extends AbstractGraphRunner {
675
867
  }
676
868
  update(def) {
677
869
  // Remote: forward update; ignore errors (fire-and-forget)
678
- this.ensureRemoteRunner().then(async (runner) => {
870
+ this.ensureClient().then(async (client) => {
679
871
  try {
680
- await runner.update(def);
872
+ await client.update(def);
681
873
  this.emit("invalidate", { reason: "graph-updated" });
682
874
  this.lastDef = def;
683
875
  }
@@ -687,14 +879,14 @@ class RemoteGraphRunner extends AbstractGraphRunner {
687
879
  launch(def, opts) {
688
880
  super.launch(def, opts);
689
881
  // Remote: build remotely then launch
690
- this.ensureRemoteRunner().then(async (runner) => {
691
- await runner.build(def);
882
+ this.ensureClient().then(async (client) => {
883
+ await client.build(def);
692
884
  // Signal UI after remote build as well
693
885
  this.emit("invalidate", { reason: "graph-built" });
694
886
  this.lastDef = def;
695
887
  // Hydrate current remote inputs/outputs (including defaults) into cache
696
888
  try {
697
- const snap = await runner.snapshot();
889
+ const snap = await client.snapshot();
698
890
  for (const [nodeId, map] of Object.entries(snap.inputs || {})) {
699
891
  for (const [handle, value] of Object.entries(map || {})) {
700
892
  this.valueCache.set(`${nodeId}.${handle}`, {
@@ -717,7 +909,47 @@ class RemoteGraphRunner extends AbstractGraphRunner {
717
909
  catch {
718
910
  console.error("Failed to hydrate remote inputs/outputs");
719
911
  }
720
- const eng = runner.getEngine();
912
+ const eng = client.getEngine();
913
+ if (!this.listenersBound) {
914
+ eng.on("value", (e) => {
915
+ this.valueCache.set(`${e.nodeId}.${e.handle}`, {
916
+ io: e.io,
917
+ value: e.value,
918
+ runtimeTypeId: e.runtimeTypeId,
919
+ });
920
+ this.emit("value", e);
921
+ });
922
+ eng.on("error", (e) => this.emit("error", e));
923
+ eng.on("invalidate", (e) => this.emit("invalidate", e));
924
+ eng.on("stats", (e) => this.emit("stats", e));
925
+ this.listenersBound = true;
926
+ }
927
+ this.engine = eng;
928
+ this.engine.launch(opts.invalidate);
929
+ this.runningKind = "push";
930
+ this.emit("status", { running: true, engine: this.runningKind });
931
+ for (const [nodeId, map] of Object.entries(this.stagedInputs)) {
932
+ this.engine.setInputs(nodeId, map);
933
+ }
934
+ });
935
+ }
936
+ /**
937
+ * Launch using an existing backend runtime that has already been built and hydrated.
938
+ * This is used when resuming from a snapshot where the backend has already applied
939
+ * ApplySnapshotFull (which builds the graph and hydrates inputs/outputs).
940
+ * Unlike launch(), this method does NOT call client.build() to avoid destroying
941
+ * the runtime state that was just restored.
942
+ */
943
+ launchExisting(def, opts) {
944
+ super.launch(def, opts);
945
+ // Remote: attach to existing runtime and launch (do NOT rebuild)
946
+ this.ensureClient().then(async (client) => {
947
+ // NOTE: We do NOT call client.build(def) here because the backend runtime
948
+ // has already been built and hydrated via ApplySnapshotFull.
949
+ // Calling build() would create a new runtime and lose the restored state.
950
+ this.lastDef = def;
951
+ // Attach to the existing engine
952
+ const eng = client.getEngine();
721
953
  if (!this.listenersBound) {
722
954
  eng.on("value", (e) => {
723
955
  this.valueCache.set(`${e.nodeId}.${e.handle}`, {
@@ -751,26 +983,26 @@ class RemoteGraphRunner extends AbstractGraphRunner {
751
983
  console.warn("Unsupported operation for remote runner");
752
984
  }
753
985
  triggerExternal(nodeId, event) {
754
- this.ensureRemoteRunner().then(async (runner) => {
986
+ this.ensureClient().then(async (client) => {
755
987
  try {
756
- await runner.getEngine().triggerExternal(nodeId, event);
988
+ await client.getEngine().triggerExternal(nodeId, event);
757
989
  }
758
990
  catch { }
759
991
  });
760
992
  }
761
993
  async coerce(from, to, value) {
762
- const runner = await this.ensureRemoteRunner();
994
+ const client = await this.ensureClient();
763
995
  try {
764
- return await runner.coerce(from, to, value);
996
+ return await client.coerce(from, to, value);
765
997
  }
766
998
  catch {
767
999
  return value;
768
1000
  }
769
1001
  }
770
1002
  async snapshotFull() {
771
- const runner = await this.ensureRemoteRunner();
1003
+ const client = await this.ensureClient();
772
1004
  try {
773
- return await runner.snapshotFull();
1005
+ return await client.snapshotFull();
774
1006
  }
775
1007
  catch {
776
1008
  return { def: undefined, environment: {}, inputs: {}, outputs: {} };
@@ -778,17 +1010,17 @@ class RemoteGraphRunner extends AbstractGraphRunner {
778
1010
  }
779
1011
  async applySnapshotFull(payload) {
780
1012
  // Hydrate local cache first so UI can display values immediately
781
- this.hydrateCacheFromSnapshot(payload);
1013
+ this.hydrateSnapshotFull(payload);
782
1014
  // Then sync with backend
783
- const runner = await this.ensureRemoteRunner();
784
- await runner.applySnapshotFull(payload);
1015
+ const client = await this.ensureClient();
1016
+ await client.applySnapshotFull(payload);
785
1017
  }
786
1018
  /**
787
1019
  * Hydrates the local valueCache from a snapshot and emits value events.
788
1020
  * This ensures the UI can display inputs/outputs immediately without waiting
789
1021
  * for value events from the remote backend.
790
1022
  */
791
- hydrateCacheFromSnapshot(snapshot) {
1023
+ hydrateSnapshotFull(snapshot) {
792
1024
  // Hydrate inputs
793
1025
  for (const [nodeId, map] of Object.entries(snapshot.inputs || {})) {
794
1026
  for (const [handle, value] of Object.entries(map || {})) {
@@ -811,20 +1043,25 @@ class RemoteGraphRunner extends AbstractGraphRunner {
811
1043
  }
812
1044
  }
813
1045
  setEnvironment(env, opts) {
814
- const t = this.transport;
815
- if (!t)
816
- return;
817
- t.request({
818
- message: {
819
- type: "SetEnvironment",
820
- payload: { environment: env, merge: opts?.merge },
821
- },
822
- }).catch(() => { });
1046
+ // Use client if available, otherwise ensure client and then set environment
1047
+ if (this.client) {
1048
+ this.client.setEnvironment(env, opts).catch(() => { });
1049
+ }
1050
+ else {
1051
+ // If client not ready yet, ensure it and then set environment
1052
+ this.ensureClient()
1053
+ .then((client) => {
1054
+ client.setEnvironment(env, opts).catch(() => { });
1055
+ })
1056
+ .catch(() => { });
1057
+ }
823
1058
  }
824
1059
  getEnvironment() {
825
- // Fetch from remote via lightweight command
826
- // Note: returns undefined synchronously; callers needing value should use snapshotFull or call runner directly
827
- // For now, we expose an async helper on RemoteRunner. Keep sync signature per interface.
1060
+ // Interface requires sync return, but RuntimeApiClient.getEnvironment() is async.
1061
+ // Returns undefined synchronously; callers needing the actual value should:
1062
+ // - Use snapshotFull() which includes environment
1063
+ // - Call client.getEnvironment() directly if they have access to the client
1064
+ // This is a limitation of the sync interface signature.
828
1065
  return undefined;
829
1066
  }
830
1067
  getOutputs(def) {
@@ -888,183 +1125,37 @@ class RemoteGraphRunner extends AbstractGraphRunner {
888
1125
  return out;
889
1126
  }
890
1127
  dispose() {
1128
+ // Idempotent: allow multiple calls safely
1129
+ if (this.disposed)
1130
+ return;
1131
+ this.disposed = true;
891
1132
  super.dispose();
892
- this.runner = undefined;
893
- this.transport = undefined;
1133
+ // Clear client promise if any
1134
+ this.clientPromise = undefined;
1135
+ // Unsubscribe from custom events and transport status
1136
+ if (this.customEventUnsubscribe) {
1137
+ this.customEventUnsubscribe();
1138
+ this.customEventUnsubscribe = undefined;
1139
+ }
1140
+ if (this.transportStatusUnsubscribe) {
1141
+ this.transportStatusUnsubscribe();
1142
+ this.transportStatusUnsubscribe = undefined;
1143
+ }
1144
+ // Dispose client (which will close transport)
1145
+ const clientToDispose = this.client;
1146
+ this.client = undefined;
894
1147
  this.registryFetched = false; // Reset so registry is fetched again on reconnect
895
1148
  this.registryFetching = false; // Reset fetching state
1149
+ if (clientToDispose) {
1150
+ clientToDispose.dispose().catch((err) => {
1151
+ console.warn("[RemoteGraphRunner] Error disposing client:", err);
1152
+ });
1153
+ }
896
1154
  this.emit("transport", {
897
1155
  state: "disconnected",
898
1156
  kind: this.backend.kind,
899
1157
  });
900
1158
  }
901
- /**
902
- * Fetch full registry description from remote and register it locally.
903
- * Called automatically on first connection with retry mechanism.
904
- */
905
- async fetchRegistry(runner, attempt = 1) {
906
- if (this.registryFetching) {
907
- // Already fetching, don't start another fetch
908
- return;
909
- }
910
- this.registryFetching = true;
911
- try {
912
- const desc = await runner.describeRegistry();
913
- // Register types
914
- for (const t of desc.types) {
915
- if (t.options) {
916
- this.registry.registerEnum({
917
- id: t.id,
918
- options: t.options,
919
- bakeTarget: t.bakeTarget,
920
- });
921
- }
922
- else {
923
- if (!this.registry.types.has(t.id)) {
924
- this.registry.registerType({
925
- id: t.id,
926
- displayName: t.displayName,
927
- validate: (_v) => true,
928
- bakeTarget: t.bakeTarget,
929
- });
930
- }
931
- }
932
- }
933
- // Register categories
934
- for (const c of desc.categories || []) {
935
- if (!this.registry.categories.has(c.id)) {
936
- // Create placeholder category descriptor
937
- const category = {
938
- id: c.id,
939
- displayName: c.displayName,
940
- createRuntime: () => ({
941
- async onInputsChanged() { },
942
- }),
943
- policy: { asyncConcurrency: "switch" },
944
- };
945
- this.registry.categories.register(category);
946
- }
947
- }
948
- // Register coercions
949
- for (const c of desc.coercions) {
950
- if (c.async) {
951
- this.registry.registerAsyncCoercion(c.from, c.to, async (v) => v, {
952
- nonTransitive: c.nonTransitive,
953
- });
954
- }
955
- else {
956
- this.registry.registerCoercion(c.from, c.to, (v) => v, {
957
- nonTransitive: c.nonTransitive,
958
- });
959
- }
960
- }
961
- // Register nodes
962
- for (const n of desc.nodes) {
963
- if (!this.registry.nodes.has(n.id)) {
964
- this.registry.registerNode({
965
- id: n.id,
966
- categoryId: n.categoryId,
967
- displayName: n.displayName,
968
- inputs: n.inputs || {},
969
- outputs: n.outputs || {},
970
- impl: () => { },
971
- });
972
- }
973
- }
974
- this.registryFetched = true;
975
- this.registryFetching = false;
976
- this.emit("registry", this.registry);
977
- }
978
- catch (err) {
979
- this.registryFetching = false;
980
- const error = err instanceof Error ? err : new Error(String(err));
981
- // Retry with exponential backoff if attempts remaining
982
- if (attempt < this.MAX_REGISTRY_FETCH_ATTEMPTS) {
983
- const delayMs = this.INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
984
- console.warn(`Failed to fetch registry (attempt ${attempt}/${this.MAX_REGISTRY_FETCH_ATTEMPTS}), retrying in ${delayMs}ms...`, error);
985
- // Emit error event for UI feedback
986
- this.emit("error", {
987
- kind: "registry",
988
- message: `Registry fetch failed (attempt ${attempt}/${this.MAX_REGISTRY_FETCH_ATTEMPTS}), retrying...`,
989
- err: error,
990
- attempt,
991
- maxAttempts: this.MAX_REGISTRY_FETCH_ATTEMPTS,
992
- });
993
- // Retry after delay
994
- setTimeout(() => {
995
- this.fetchRegistry(runner, attempt + 1).catch(() => {
996
- // Final failure handled below
997
- });
998
- }, delayMs);
999
- }
1000
- else {
1001
- // Max attempts reached, emit final error
1002
- console.error(`Failed to fetch registry after ${this.MAX_REGISTRY_FETCH_ATTEMPTS} attempts:`, error);
1003
- this.emit("error", {
1004
- kind: "registry",
1005
- message: `Failed to fetch registry after ${this.MAX_REGISTRY_FETCH_ATTEMPTS} attempts. Please check your connection and try refreshing.`,
1006
- err: error,
1007
- attempt: this.MAX_REGISTRY_FETCH_ATTEMPTS,
1008
- maxAttempts: this.MAX_REGISTRY_FETCH_ATTEMPTS,
1009
- });
1010
- }
1011
- }
1012
- }
1013
- // Ensure remote transport/runner
1014
- async ensureRemoteRunner() {
1015
- if (this.runner)
1016
- return this.runner;
1017
- let transport;
1018
- const kind = this.backend.kind;
1019
- const backend = this.backend;
1020
- const connectOptions = backend.connectOptions;
1021
- this.emit("transport", { state: "connecting", kind });
1022
- if (backend.kind === "remote-http") {
1023
- if (!HttpPollingTransport)
1024
- throw new Error("HttpPollingTransport not available");
1025
- transport = new HttpPollingTransport(backend.baseUrl);
1026
- await transport.connect(connectOptions);
1027
- }
1028
- else if (backend.kind === "remote-ws") {
1029
- if (!WebSocketTransport)
1030
- throw new Error("WebSocketTransport not available");
1031
- transport = new WebSocketTransport(backend.url);
1032
- await transport.connect(connectOptions);
1033
- }
1034
- else {
1035
- throw new Error("Remote backend not configured");
1036
- }
1037
- // Subscribe to custom events if handler provided
1038
- if (backend.onCustomEvent) {
1039
- transport.subscribe((event) => {
1040
- // Filter out standard runtime events, pass others to custom handler
1041
- const msg = event.message;
1042
- if (msg && typeof msg === "object" && "type" in msg) {
1043
- const type = msg.type;
1044
- // Standard runtime events: stats, value, error, invalidate
1045
- // Custom events are anything else (e.g., flow-opened, flow-latest)
1046
- if (!["stats", "value", "error", "invalidate"].includes(type)) {
1047
- backend.onCustomEvent?.(event);
1048
- }
1049
- }
1050
- });
1051
- }
1052
- const runner = new RemoteRunner(transport);
1053
- this.runner = runner;
1054
- this.transport = transport;
1055
- this.valueCache.clear();
1056
- this.listenersBound = false;
1057
- this.emit("transport", { state: "connected", kind });
1058
- // Auto-fetch registry on first connection (only once)
1059
- if (!this.registryFetched && !this.registryFetching) {
1060
- // Log loading state (UI can listen to transport status for loading indication)
1061
- console.info("Loading registry from remote...");
1062
- this.fetchRegistry(runner).catch(() => {
1063
- // Error handling is done inside fetchRegistry
1064
- });
1065
- }
1066
- return runner;
1067
- }
1068
1159
  }
1069
1160
 
1070
1161
  function useWorkbenchBridge(wb) {
@@ -1732,6 +1823,33 @@ function getNodeBorderClassNames(args) {
1732
1823
  : "";
1733
1824
  return [borderWidth, borderStyle, borderColor, ring].join(" ").trim();
1734
1825
  }
1826
+ /**
1827
+ * Shared utility to generate handle className based on validation and value state.
1828
+ * - Linked handles (with inbound edges) get black borders
1829
+ * - Handles with values (but not linked) get darker gray borders
1830
+ * - Handles with only defaults (no value, not linked) get lighter gray borders
1831
+ * - Validation errors (red/amber) take precedence over value-based styling.
1832
+ */
1833
+ function getHandleClassName(args) {
1834
+ const { kind, id, validation } = args;
1835
+ const vIssues = (kind === "input" ? validation.inputs : validation.outputs)?.filter((v) => v.handle === id) || [];
1836
+ const hasAny = vIssues.length > 0;
1837
+ const hasErr = vIssues.some((v) => v.level === "error");
1838
+ // Determine border color based on priority:
1839
+ // 1. Validation errors (red/amber) - highest priority
1840
+ // 2. Gray border
1841
+ let borderColor;
1842
+ if (hasAny && hasErr) {
1843
+ borderColor = "!border-red-500";
1844
+ }
1845
+ else if (hasAny) {
1846
+ borderColor = "!border-amber-500";
1847
+ }
1848
+ else {
1849
+ borderColor = "!border-gray-500 dark:!border-gray-400";
1850
+ }
1851
+ return cx("!w-3 !h-3 !bg-white !dark:bg-stone-900", borderColor, kind === "output" && "!rounded-none");
1852
+ }
1735
1853
 
1736
1854
  const WorkbenchContext = createContext(null);
1737
1855
  function useWorkbenchContext() {
@@ -2454,7 +2572,7 @@ function DebugEvents({ autoScroll, onAutoScrollChange, hideWorkbench, onHideWork
2454
2572
  return (jsxs("div", { className: "flex flex-col h-full min-h-0", children: [jsxs("div", { className: "flex items-center justify-between mb-1", children: [jsx("div", { className: "font-semibold", children: "Events" }), jsxs("div", { className: "flex items-center gap-2", children: [jsxs("label", { className: "flex items-center gap-1 text-xs text-gray-700", children: [jsx("input", { type: "checkbox", checked: hideWorkbench, onChange: (e) => onHideWorkbenchChange?.(e.target.checked) }), jsx("span", { children: "Hide workbench" })] }), jsxs("label", { className: "flex items-center gap-1 text-xs text-gray-700", children: [jsx("input", { type: "checkbox", checked: autoScroll, onChange: (e) => onAutoScrollChange?.(e.target.checked) }), jsx("span", { children: "Auto scroll" })] }), jsx("button", { onClick: handleCopyLogs, className: "p-2 border border-gray-300 rounded flex items-center justify-center", title: copied ? "Copied!" : "Copy logs as formatted JSON", children: jsx(CopyIcon, { size: 14 }) }), jsx("button", { onClick: clearEvents, className: "p-2 border border-gray-300 rounded flex items-center justify-center", title: "Clear all events", children: jsx(TrashIcon, { size: 14 }) })] })] }), jsx("div", { ref: scrollRef, className: "flex-1 overflow-auto text-[11px] leading-4 divide-y divide-gray-200", children: rows.map((ev) => (jsxs("div", { className: "opacity-85 odd:bg-gray-50 px-2 py-1", children: [jsxs("div", { className: "flex items-baseline gap-2", children: [jsx("span", { className: "w-12 shrink-0 text-right text-gray-500 select-none", children: ev.no }), jsxs("span", { className: "text-gray-500", children: [new Date(ev.at).toLocaleTimeString(), " \u00B7 ", ev.source, ":", ev.type] })] }), jsx("pre", { className: "m-0 whitespace-pre-wrap ml-12", children: renderPayload(ev.payload) })] }, `${ev.at}:${ev.no}`))) })] }));
2455
2573
  }
2456
2574
 
2457
- function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHideWorkbenchChange, toString, toElement, contextPanel, setInput, }) {
2575
+ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHideWorkbenchChange, toString, contextPanel, setInput, }) {
2458
2576
  const safeToString = (typeId, value) => {
2459
2577
  try {
2460
2578
  if (typeof toString === "function") {
@@ -2832,15 +2950,17 @@ function DefaultNodeContent({ data, isConnectable, }) {
2832
2950
  const status = data.status ?? { activeRuns: 0 };
2833
2951
  const validation = data.validation ?? {
2834
2952
  inputs: [],
2835
- outputs: []};
2953
+ outputs: [],
2954
+ issues: [],
2955
+ };
2836
2956
  const isRunning = !!status.activeRuns;
2837
2957
  const pct = Math.round(Math.max(0, Math.min(1, Number(status.progress) || 0)) * 100);
2838
- return (jsxs(Fragment, { children: [jsx("div", { className: cx("h-px", (isRunning || pct > 0) && "bg-blue-200 dark:bg-blue-900"), children: jsx("div", { className: cx("h-px transition-all", (isRunning || pct > 0) && "bg-blue-500"), style: { width: isRunning || pct > 0 ? `${pct}%` : 0 } }) }), jsx(NodeHandles, { data: data, isConnectable: isConnectable, getClassName: ({ kind, id }) => {
2839
- const vIssues = (kind === "input" ? validation.inputs : validation.outputs).filter((v) => v.handle === id);
2840
- const hasAny = vIssues.length > 0;
2841
- const hasErr = vIssues.some((v) => v.level === "error");
2842
- return cx("!w-3 !h-3 !bg-white !dark:bg-stone-900 !border-gray-500 dark:!border-gray-400", kind === "output" && "!rounded-none", hasAny && (hasErr ? "!border-red-500" : "!border-amber-500"));
2843
- }, renderLabel: ({ kind, id: handleId }) => {
2958
+ return (jsxs(Fragment, { children: [jsx("div", { className: cx("h-px", (isRunning || pct > 0) && "bg-blue-200 dark:bg-blue-900"), children: jsx("div", { className: cx("h-px transition-all", (isRunning || pct > 0) && "bg-blue-500"), style: { width: isRunning || pct > 0 ? `${pct}%` : 0 } }) }), jsx(NodeHandles, { data: data, isConnectable: isConnectable, getClassName: ({ kind, id }) => getHandleClassName({
2959
+ kind,
2960
+ id,
2961
+ validation,
2962
+ inputConnected: data.inputConnected,
2963
+ }), renderLabel: ({ kind, id: handleId }) => {
2844
2964
  const entries = kind === "input" ? inputEntries : outputEntries;
2845
2965
  const entry = entries.find((e) => e.id === handleId);
2846
2966
  if (!entry)
@@ -3446,9 +3566,6 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3446
3566
  state: "local",
3447
3567
  });
3448
3568
  const selectedNode = def.nodes.find((n) => n.nodeId === selectedNodeId);
3449
- selectedNode
3450
- ? registry.nodes.get(selectedNode.typeId)
3451
- : undefined;
3452
3569
  const effectiveHandles = selectedNode
3453
3570
  ? computeEffectiveHandles(selectedNode, registry)
3454
3571
  : { inputs: {}, outputs: {}, inputDefaults: {} };
@@ -3739,7 +3856,12 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3739
3856
  const isLinked = def.edges.some((e) => e.target.nodeId === selectedNodeId && e.target.handle === handle);
3740
3857
  if (isLinked)
3741
3858
  return;
3742
- const typeId = effectiveHandles.inputs[handle];
3859
+ // If raw is undefined, pass it through to delete the input value
3860
+ if (raw === undefined) {
3861
+ runner.setInput(selectedNodeId, handle, undefined);
3862
+ return;
3863
+ }
3864
+ const typeId = getInputTypeId(effectiveHandles.inputs, handle);
3743
3865
  let value = raw;
3744
3866
  const parseArray = (s, map) => {
3745
3867
  const str = String(s).trim();
@@ -3948,7 +4070,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3948
4070
  const message = err instanceof Error ? err.message : String(err);
3949
4071
  alert(message);
3950
4072
  }
3951
- }, 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, toElement: toElement, contextPanel: overrides?.contextPanel })] })] }));
4073
+ }, 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 })] })] }));
3952
4074
  }
3953
4075
  function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, backendOptions, overrides, onInit, onChange, }) {
3954
4076
  const [registry, setRegistry] = useState(createSimpleGraphRegistry());
@@ -4029,5 +4151,5 @@ function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, bac
4029
4151
  }, 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 }) }));
4030
4152
  }
4031
4153
 
4032
- export { AbstractWorkbench, CLIWorkbench, DefaultNode, DefaultNodeContent, DefaultNodeHeader, DefaultUIExtensionRegistry, InMemoryWorkbench, Inspector, LocalGraphRunner, NodeHandles, RemoteGraphRunner, WorkbenchCanvas, WorkbenchContext, WorkbenchProvider, WorkbenchStudio, formatDataUrlAsLabel, formatDeclaredTypeSignature, getNodeBorderClassNames, preformatValueForDisplay, prettyHandle, resolveOutputDisplay, summarizeDeep, toReactFlow, useQueryParamBoolean, useQueryParamString, useThrottledValue, useWorkbenchBridge, useWorkbenchContext, useWorkbenchGraphTick, useWorkbenchGraphUiTick, useWorkbenchVersionTick };
4154
+ 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 };
4033
4155
  //# sourceMappingURL=index.js.map