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