@bian-womp/spark-workbench 0.2.36 → 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.
Files changed (33) hide show
  1. package/lib/cjs/index.cjs +405 -221
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/index.d.ts +1 -0
  4. package/lib/cjs/src/index.d.ts.map +1 -1
  5. package/lib/cjs/src/misc/DefaultNode.d.ts.map +1 -1
  6. package/lib/cjs/src/misc/Inspector.d.ts +1 -2
  7. package/lib/cjs/src/misc/Inspector.d.ts.map +1 -1
  8. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  9. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts +4 -1
  10. package/lib/cjs/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  11. package/lib/cjs/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  12. package/lib/cjs/src/misc/mapping.d.ts +19 -0
  13. package/lib/cjs/src/misc/mapping.d.ts.map +1 -1
  14. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  15. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +32 -17
  16. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  17. package/lib/esm/index.js +402 -223
  18. package/lib/esm/index.js.map +1 -1
  19. package/lib/esm/src/index.d.ts +1 -0
  20. package/lib/esm/src/index.d.ts.map +1 -1
  21. package/lib/esm/src/misc/DefaultNode.d.ts.map +1 -1
  22. package/lib/esm/src/misc/Inspector.d.ts +1 -2
  23. package/lib/esm/src/misc/Inspector.d.ts.map +1 -1
  24. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  25. package/lib/esm/src/misc/context/WorkbenchContext.d.ts +4 -1
  26. package/lib/esm/src/misc/context/WorkbenchContext.d.ts.map +1 -1
  27. package/lib/esm/src/misc/context/WorkbenchContext.provider.d.ts.map +1 -1
  28. package/lib/esm/src/misc/mapping.d.ts +19 -0
  29. package/lib/esm/src/misc/mapping.d.ts.map +1 -1
  30. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  31. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +32 -17
  32. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  33. package/package.json +4 -4
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 {
@@ -343,7 +343,12 @@ class AbstractGraphRunner {
343
343
  setInput(nodeId, handle, value) {
344
344
  if (!this.stagedInputs[nodeId])
345
345
  this.stagedInputs[nodeId] = {};
346
- this.stagedInputs[nodeId][handle] = value;
346
+ if (value === undefined) {
347
+ delete this.stagedInputs[nodeId][handle];
348
+ }
349
+ else {
350
+ this.stagedInputs[nodeId][handle] = value;
351
+ }
347
352
  if (this.engine) {
348
353
  this.engine.setInput(nodeId, handle, value);
349
354
  }
@@ -361,7 +366,14 @@ class AbstractGraphRunner {
361
366
  return;
362
367
  if (!this.stagedInputs[nodeId])
363
368
  this.stagedInputs[nodeId] = {};
364
- Object.assign(this.stagedInputs[nodeId], inputs);
369
+ for (const [handle, value] of Object.entries(inputs)) {
370
+ if (value === undefined) {
371
+ delete this.stagedInputs[nodeId][handle];
372
+ }
373
+ else {
374
+ this.stagedInputs[nodeId][handle] = value;
375
+ }
376
+ }
365
377
  if (this.engine) {
366
378
  // Running: set all inputs
367
379
  this.engine.setInputs(nodeId, inputs);
@@ -590,8 +602,200 @@ class LocalGraphRunner extends AbstractGraphRunner {
590
602
  }
591
603
 
592
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
+ }
593
796
  constructor(registry, backend) {
594
797
  super(registry, backend);
798
+ this.disposed = false;
595
799
  this.valueCache = new Map();
596
800
  this.listenersBound = false;
597
801
  this.registryFetched = false;
@@ -600,8 +804,8 @@ class RemoteGraphRunner extends AbstractGraphRunner {
600
804
  this.INITIAL_RETRY_DELAY_MS = 1000; // 1 second
601
805
  // Auto-handle registry-changed invalidations from remote
602
806
  // We listen on invalidate and if reason matches, we rehydrate registry and emit a registry event
603
- this.ensureRemoteRunner().then(async (runner) => {
604
- const eng = runner.getEngine();
807
+ this.ensureClient().then(async (client) => {
808
+ const eng = client.getEngine();
605
809
  if (!this.listenersBound) {
606
810
  eng.on("invalidate", async (e) => {
607
811
  if (e.reason === "registry-changed") {
@@ -665,9 +869,9 @@ class RemoteGraphRunner extends AbstractGraphRunner {
665
869
  }
666
870
  update(def) {
667
871
  // Remote: forward update; ignore errors (fire-and-forget)
668
- this.ensureRemoteRunner().then(async (runner) => {
872
+ this.ensureClient().then(async (client) => {
669
873
  try {
670
- await runner.update(def);
874
+ await client.update(def);
671
875
  this.emit("invalidate", { reason: "graph-updated" });
672
876
  this.lastDef = def;
673
877
  }
@@ -677,14 +881,14 @@ class RemoteGraphRunner extends AbstractGraphRunner {
677
881
  launch(def, opts) {
678
882
  super.launch(def, opts);
679
883
  // Remote: build remotely then launch
680
- this.ensureRemoteRunner().then(async (runner) => {
681
- await runner.build(def);
884
+ this.ensureClient().then(async (client) => {
885
+ await client.build(def);
682
886
  // Signal UI after remote build as well
683
887
  this.emit("invalidate", { reason: "graph-built" });
684
888
  this.lastDef = def;
685
889
  // Hydrate current remote inputs/outputs (including defaults) into cache
686
890
  try {
687
- const snap = await runner.snapshot();
891
+ const snap = await client.snapshot();
688
892
  for (const [nodeId, map] of Object.entries(snap.inputs || {})) {
689
893
  for (const [handle, value] of Object.entries(map || {})) {
690
894
  this.valueCache.set(`${nodeId}.${handle}`, {
@@ -707,7 +911,47 @@ class RemoteGraphRunner extends AbstractGraphRunner {
707
911
  catch {
708
912
  console.error("Failed to hydrate remote inputs/outputs");
709
913
  }
710
- 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();
711
955
  if (!this.listenersBound) {
712
956
  eng.on("value", (e) => {
713
957
  this.valueCache.set(`${e.nodeId}.${e.handle}`, {
@@ -741,26 +985,26 @@ class RemoteGraphRunner extends AbstractGraphRunner {
741
985
  console.warn("Unsupported operation for remote runner");
742
986
  }
743
987
  triggerExternal(nodeId, event) {
744
- this.ensureRemoteRunner().then(async (runner) => {
988
+ this.ensureClient().then(async (client) => {
745
989
  try {
746
- await runner.getEngine().triggerExternal(nodeId, event);
990
+ await client.getEngine().triggerExternal(nodeId, event);
747
991
  }
748
992
  catch { }
749
993
  });
750
994
  }
751
995
  async coerce(from, to, value) {
752
- const runner = await this.ensureRemoteRunner();
996
+ const client = await this.ensureClient();
753
997
  try {
754
- return await runner.coerce(from, to, value);
998
+ return await client.coerce(from, to, value);
755
999
  }
756
1000
  catch {
757
1001
  return value;
758
1002
  }
759
1003
  }
760
1004
  async snapshotFull() {
761
- const runner = await this.ensureRemoteRunner();
1005
+ const client = await this.ensureClient();
762
1006
  try {
763
- return await runner.snapshotFull();
1007
+ return await client.snapshotFull();
764
1008
  }
765
1009
  catch {
766
1010
  return { def: undefined, environment: {}, inputs: {}, outputs: {} };
@@ -768,17 +1012,17 @@ class RemoteGraphRunner extends AbstractGraphRunner {
768
1012
  }
769
1013
  async applySnapshotFull(payload) {
770
1014
  // Hydrate local cache first so UI can display values immediately
771
- this.hydrateCacheFromSnapshot(payload);
1015
+ this.hydrateSnapshotFull(payload);
772
1016
  // Then sync with backend
773
- const runner = await this.ensureRemoteRunner();
774
- await runner.applySnapshotFull(payload);
1017
+ const client = await this.ensureClient();
1018
+ await client.applySnapshotFull(payload);
775
1019
  }
776
1020
  /**
777
1021
  * Hydrates the local valueCache from a snapshot and emits value events.
778
1022
  * This ensures the UI can display inputs/outputs immediately without waiting
779
1023
  * for value events from the remote backend.
780
1024
  */
781
- hydrateCacheFromSnapshot(snapshot) {
1025
+ hydrateSnapshotFull(snapshot) {
782
1026
  // Hydrate inputs
783
1027
  for (const [nodeId, map] of Object.entries(snapshot.inputs || {})) {
784
1028
  for (const [handle, value] of Object.entries(map || {})) {
@@ -801,20 +1045,25 @@ class RemoteGraphRunner extends AbstractGraphRunner {
801
1045
  }
802
1046
  }
803
1047
  setEnvironment(env, opts) {
804
- const t = this.transport;
805
- if (!t)
806
- return;
807
- t.request({
808
- message: {
809
- type: "SetEnvironment",
810
- payload: { environment: env, merge: opts?.merge },
811
- },
812
- }).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
+ }
813
1060
  }
814
1061
  getEnvironment() {
815
- // Fetch from remote via lightweight command
816
- // Note: returns undefined synchronously; callers needing value should use snapshotFull or call runner directly
817
- // 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.
818
1067
  return undefined;
819
1068
  }
820
1069
  getOutputs(def) {
@@ -878,183 +1127,37 @@ class RemoteGraphRunner extends AbstractGraphRunner {
878
1127
  return out;
879
1128
  }
880
1129
  dispose() {
1130
+ // Idempotent: allow multiple calls safely
1131
+ if (this.disposed)
1132
+ return;
1133
+ this.disposed = true;
881
1134
  super.dispose();
882
- this.runner = undefined;
883
- 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;
884
1149
  this.registryFetched = false; // Reset so registry is fetched again on reconnect
885
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
+ }
886
1156
  this.emit("transport", {
887
1157
  state: "disconnected",
888
1158
  kind: this.backend.kind,
889
1159
  });
890
1160
  }
891
- /**
892
- * Fetch full registry description from remote and register it locally.
893
- * Called automatically on first connection with retry mechanism.
894
- */
895
- async fetchRegistry(runner, attempt = 1) {
896
- if (this.registryFetching) {
897
- // Already fetching, don't start another fetch
898
- return;
899
- }
900
- this.registryFetching = true;
901
- try {
902
- const desc = await runner.describeRegistry();
903
- // Register types
904
- for (const t of desc.types) {
905
- if (t.options) {
906
- this.registry.registerEnum({
907
- id: t.id,
908
- options: t.options,
909
- bakeTarget: t.bakeTarget,
910
- });
911
- }
912
- else {
913
- if (!this.registry.types.has(t.id)) {
914
- this.registry.registerType({
915
- id: t.id,
916
- displayName: t.displayName,
917
- validate: (_v) => true,
918
- bakeTarget: t.bakeTarget,
919
- });
920
- }
921
- }
922
- }
923
- // Register categories
924
- for (const c of desc.categories || []) {
925
- if (!this.registry.categories.has(c.id)) {
926
- // Create placeholder category descriptor
927
- const category = {
928
- id: c.id,
929
- displayName: c.displayName,
930
- createRuntime: () => ({
931
- async onInputsChanged() { },
932
- }),
933
- policy: { asyncConcurrency: "switch" },
934
- };
935
- this.registry.categories.register(category);
936
- }
937
- }
938
- // Register coercions
939
- for (const c of desc.coercions) {
940
- if (c.async) {
941
- this.registry.registerAsyncCoercion(c.from, c.to, async (v) => v, {
942
- nonTransitive: c.nonTransitive,
943
- });
944
- }
945
- else {
946
- this.registry.registerCoercion(c.from, c.to, (v) => v, {
947
- nonTransitive: c.nonTransitive,
948
- });
949
- }
950
- }
951
- // Register nodes
952
- for (const n of desc.nodes) {
953
- if (!this.registry.nodes.has(n.id)) {
954
- this.registry.registerNode({
955
- id: n.id,
956
- categoryId: n.categoryId,
957
- displayName: n.displayName,
958
- inputs: n.inputs || {},
959
- outputs: n.outputs || {},
960
- impl: () => { },
961
- });
962
- }
963
- }
964
- this.registryFetched = true;
965
- this.registryFetching = false;
966
- this.emit("registry", this.registry);
967
- }
968
- catch (err) {
969
- this.registryFetching = false;
970
- const error = err instanceof Error ? err : new Error(String(err));
971
- // Retry with exponential backoff if attempts remaining
972
- if (attempt < this.MAX_REGISTRY_FETCH_ATTEMPTS) {
973
- const delayMs = this.INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
974
- console.warn(`Failed to fetch registry (attempt ${attempt}/${this.MAX_REGISTRY_FETCH_ATTEMPTS}), retrying in ${delayMs}ms...`, error);
975
- // Emit error event for UI feedback
976
- this.emit("error", {
977
- kind: "registry",
978
- message: `Registry fetch failed (attempt ${attempt}/${this.MAX_REGISTRY_FETCH_ATTEMPTS}), retrying...`,
979
- err: error,
980
- attempt,
981
- maxAttempts: this.MAX_REGISTRY_FETCH_ATTEMPTS,
982
- });
983
- // Retry after delay
984
- setTimeout(() => {
985
- this.fetchRegistry(runner, attempt + 1).catch(() => {
986
- // Final failure handled below
987
- });
988
- }, delayMs);
989
- }
990
- else {
991
- // Max attempts reached, emit final error
992
- console.error(`Failed to fetch registry after ${this.MAX_REGISTRY_FETCH_ATTEMPTS} attempts:`, error);
993
- this.emit("error", {
994
- kind: "registry",
995
- message: `Failed to fetch registry after ${this.MAX_REGISTRY_FETCH_ATTEMPTS} attempts. Please check your connection and try refreshing.`,
996
- err: error,
997
- attempt: this.MAX_REGISTRY_FETCH_ATTEMPTS,
998
- maxAttempts: this.MAX_REGISTRY_FETCH_ATTEMPTS,
999
- });
1000
- }
1001
- }
1002
- }
1003
- // Ensure remote transport/runner
1004
- async ensureRemoteRunner() {
1005
- if (this.runner)
1006
- return this.runner;
1007
- let transport;
1008
- const kind = this.backend.kind;
1009
- const backend = this.backend;
1010
- const connectOptions = backend.connectOptions;
1011
- this.emit("transport", { state: "connecting", kind });
1012
- if (backend.kind === "remote-http") {
1013
- if (!sparkRemote.HttpPollingTransport)
1014
- throw new Error("HttpPollingTransport not available");
1015
- transport = new sparkRemote.HttpPollingTransport(backend.baseUrl);
1016
- await transport.connect(connectOptions);
1017
- }
1018
- else if (backend.kind === "remote-ws") {
1019
- if (!sparkRemote.WebSocketTransport)
1020
- throw new Error("WebSocketTransport not available");
1021
- transport = new sparkRemote.WebSocketTransport(backend.url);
1022
- await transport.connect(connectOptions);
1023
- }
1024
- else {
1025
- throw new Error("Remote backend not configured");
1026
- }
1027
- // Subscribe to custom events if handler provided
1028
- if (backend.onCustomEvent) {
1029
- transport.subscribe((event) => {
1030
- // Filter out standard runtime events, pass others to custom handler
1031
- const msg = event.message;
1032
- if (msg && typeof msg === "object" && "type" in msg) {
1033
- const type = msg.type;
1034
- // Standard runtime events: stats, value, error, invalidate
1035
- // Custom events are anything else (e.g., flow-opened, flow-latest)
1036
- if (!["stats", "value", "error", "invalidate"].includes(type)) {
1037
- backend.onCustomEvent?.(event);
1038
- }
1039
- }
1040
- });
1041
- }
1042
- const runner = new sparkRemote.RemoteRunner(transport);
1043
- this.runner = runner;
1044
- this.transport = transport;
1045
- this.valueCache.clear();
1046
- this.listenersBound = false;
1047
- this.emit("transport", { state: "connected", kind });
1048
- // Auto-fetch registry on first connection (only once)
1049
- if (!this.registryFetched && !this.registryFetching) {
1050
- // Log loading state (UI can listen to transport status for loading indication)
1051
- console.info("Loading registry from remote...");
1052
- this.fetchRegistry(runner).catch(() => {
1053
- // Error handling is done inside fetchRegistry
1054
- });
1055
- }
1056
- return runner;
1057
- }
1058
1161
  }
1059
1162
 
1060
1163
  function useWorkbenchBridge(wb) {
@@ -1722,6 +1825,33 @@ function getNodeBorderClassNames(args) {
1722
1825
  : "";
1723
1826
  return [borderWidth, borderStyle, borderColor, ring].join(" ").trim();
1724
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
+ }
1725
1855
 
1726
1856
  const WorkbenchContext = React.createContext(null);
1727
1857
  function useWorkbenchContext() {
@@ -1738,14 +1868,19 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
1738
1868
  const clearEvents = React.useCallback(() => setEvents([]), []);
1739
1869
  const [systemErrors, setSystemErrors] = React.useState([]);
1740
1870
  const [registryErrors, setRegistryErrors] = React.useState([]);
1871
+ const [inputValidationErrors, setInputValidationErrors] = React.useState([]);
1741
1872
  const clearSystemErrors = React.useCallback(() => setSystemErrors([]), []);
1742
1873
  const clearRegistryErrors = React.useCallback(() => setRegistryErrors([]), []);
1874
+ const clearInputValidationErrors = React.useCallback(() => setInputValidationErrors([]), []);
1743
1875
  const removeSystemError = React.useCallback((index) => {
1744
1876
  setSystemErrors((prev) => prev.filter((_, idx) => idx !== index));
1745
1877
  }, []);
1746
1878
  const removeRegistryError = React.useCallback((index) => {
1747
1879
  setRegistryErrors((prev) => prev.filter((_, idx) => idx !== index));
1748
1880
  }, []);
1881
+ const removeInputValidationError = React.useCallback((index) => {
1882
+ setInputValidationErrors((prev) => prev.filter((_, idx) => idx !== index));
1883
+ }, []);
1749
1884
  // Fallback progress animation: drive progress to 100% over ~2 minutes
1750
1885
  const FALLBACK_TOTAL_MS = 2 * 60 * 1000;
1751
1886
  const [fallbackStarts, setFallbackStarts] = React.useState({});
@@ -1801,7 +1936,8 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
1801
1936
  const out = {};
1802
1937
  // Local: runtimeTypeId is not stored; derive from typed wrapper in outputsMap
1803
1938
  for (const n of def.nodes) {
1804
- const outputsDecl = registry.nodes.get(n.typeId)?.outputs ?? {};
1939
+ const effectiveHandles = computeEffectiveHandles(n, registry);
1940
+ const outputsDecl = effectiveHandles.outputs;
1805
1941
  const handles = Object.keys(outputsDecl);
1806
1942
  const cur = {};
1807
1943
  for (const h of handles) {
@@ -1962,10 +2098,13 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
1962
2098
  const offRunnerValue = runner.on("value", (e) => {
1963
2099
  if (e?.io === "input") {
1964
2100
  const nodeId = e?.nodeId;
2101
+ const handle = e?.handle;
1965
2102
  setNodeStatus((s) => ({
1966
2103
  ...s,
1967
2104
  [nodeId]: { ...s[nodeId], invalidated: true },
1968
2105
  }));
2106
+ // Clear validation errors for this input when a valid value is set
2107
+ setInputValidationErrors((prev) => prev.filter((err) => !(err.nodeId === nodeId && err.handle === handle)));
1969
2108
  }
1970
2109
  return add("runner", "value")(e);
1971
2110
  });
@@ -1974,6 +2113,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
1974
2113
  const nodeError = e;
1975
2114
  const registryError = e;
1976
2115
  const systemError = e;
2116
+ const inputValidationError = e;
1977
2117
  if (edgeError.kind === "edge-convert") {
1978
2118
  const edgeId = edgeError.edgeId;
1979
2119
  setEdgeStatus((s) => ({
@@ -2009,6 +2149,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2009
2149
  return [...prev, registryError];
2010
2150
  });
2011
2151
  }
2152
+ else if (inputValidationError.kind === "input-validation") {
2153
+ // Track input validation errors for UI display
2154
+ setInputValidationErrors((prev) => {
2155
+ // Avoid duplicates by checking nodeId, handle, and typeId
2156
+ if (prev.some((err) => err.nodeId === inputValidationError.nodeId &&
2157
+ err.handle === inputValidationError.handle &&
2158
+ err.typeId === inputValidationError.typeId)) {
2159
+ return prev;
2160
+ }
2161
+ return [...prev, inputValidationError];
2162
+ });
2163
+ }
2012
2164
  else if (systemError.kind === "system") {
2013
2165
  // Track custom errors for UI display
2014
2166
  setSystemErrors((prev) => {
@@ -2132,7 +2284,14 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2132
2284
  }
2133
2285
  return add("runner", "stats")(s);
2134
2286
  });
2135
- const offWbGraphChanged = wb.on("graphChanged", add("workbench", "graphChanged"));
2287
+ const offWbGraphChanged = wb.on("graphChanged", (event) => {
2288
+ // Clear validation errors for removed nodes
2289
+ if (event.change?.type === "removeNode") {
2290
+ const removedNodeId = event.change.nodeId;
2291
+ setInputValidationErrors((prev) => prev.filter((err) => err.nodeId !== removedNodeId));
2292
+ }
2293
+ return add("workbench", "graphChanged")(event);
2294
+ });
2136
2295
  const offWbGraphUiChanged = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
2137
2296
  const offWbValidationChanged = wb.on("validationChanged", add("workbench", "validationChanged"));
2138
2297
  // Ensure newly added nodes start as invalidated until first evaluation
@@ -2302,10 +2461,13 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2302
2461
  clearEvents,
2303
2462
  systemErrors,
2304
2463
  registryErrors,
2464
+ inputValidationErrors,
2305
2465
  clearSystemErrors,
2306
2466
  clearRegistryErrors,
2467
+ clearInputValidationErrors,
2307
2468
  removeSystemError,
2308
2469
  removeRegistryError,
2470
+ removeInputValidationError,
2309
2471
  isRunning,
2310
2472
  engineKind,
2311
2473
  start,
@@ -2330,10 +2492,13 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2330
2492
  valuesTick,
2331
2493
  systemErrors,
2332
2494
  registryErrors,
2495
+ inputValidationErrors,
2333
2496
  clearSystemErrors,
2334
2497
  clearRegistryErrors,
2498
+ clearInputValidationErrors,
2335
2499
  removeSystemError,
2336
2500
  removeRegistryError,
2501
+ removeInputValidationError,
2337
2502
  inputsMap,
2338
2503
  inputDefaultsMap,
2339
2504
  outputsMap,
@@ -2409,7 +2574,7 @@ function DebugEvents({ autoScroll, onAutoScrollChange, hideWorkbench, onHideWork
2409
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}`))) })] }));
2410
2575
  }
2411
2576
 
2412
- function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHideWorkbenchChange, toString, toElement, contextPanel, setInput, }) {
2577
+ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHideWorkbenchChange, toString, contextPanel, setInput, }) {
2413
2578
  const safeToString = (typeId, value) => {
2414
2579
  try {
2415
2580
  if (typeof toString === "function") {
@@ -2421,7 +2586,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2421
2586
  return String(value ?? "");
2422
2587
  }
2423
2588
  };
2424
- const { registry, def, selectedNodeId, selectedEdgeId, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, nodeStatus, edgeStatus, validationByNode, validationByEdge, validationGlobal, valuesTick, updateEdgeType, systemErrors, registryErrors, clearSystemErrors, clearRegistryErrors, removeSystemError, removeRegistryError, } = useWorkbenchContext();
2589
+ const { registry, def, selectedNodeId, selectedEdgeId, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, nodeStatus, edgeStatus, validationByNode, validationByEdge, validationGlobal, valuesTick, updateEdgeType, systemErrors, registryErrors, inputValidationErrors, clearSystemErrors, clearRegistryErrors, clearInputValidationErrors, removeSystemError, removeRegistryError, removeInputValidationError, } = useWorkbenchContext();
2425
2590
  const nodeValidationIssues = validationByNode.issues;
2426
2591
  const edgeValidationIssues = validationByEdge.issues;
2427
2592
  const nodeValidationHandles = validationByNode;
@@ -2532,7 +2697,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2532
2697
  }
2533
2698
  catch { }
2534
2699
  };
2535
- 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 }), 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) => {
2700
+ 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) => {
2536
2701
  e.stopPropagation();
2537
2702
  deleteEdgeById(m.data?.edgeId);
2538
2703
  }, 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] }), (() => {
@@ -2570,13 +2735,20 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2570
2735
  const current = nodeInputs[h];
2571
2736
  const hasValue = current !== undefined && current !== null;
2572
2737
  const value = drafts[h] ?? safeToString(typeId, current);
2573
- const displayValue = hasValue ? value : "";
2738
+ const displayValue = value;
2574
2739
  const placeholder = hasDefault ? defaultStr : undefined;
2575
2740
  const onChangeText = (text) => setDrafts((d) => ({ ...d, [h]: text }));
2576
2741
  const commit = () => {
2577
2742
  const draft = drafts[h];
2578
2743
  if (draft === undefined)
2579
2744
  return;
2745
+ // Only commit if draft differs from current value
2746
+ const currentDisplay = safeToString(typeId, current);
2747
+ if (draft === currentDisplay) {
2748
+ // No change, just sync originals without calling setInput
2749
+ setOriginals((o) => ({ ...o, [h]: draft }));
2750
+ return;
2751
+ }
2580
2752
  setInput(h, draft);
2581
2753
  setOriginals((o) => ({ ...o, [h]: draft }));
2582
2754
  };
@@ -2780,15 +2952,17 @@ function DefaultNodeContent({ data, isConnectable, }) {
2780
2952
  const status = data.status ?? { activeRuns: 0 };
2781
2953
  const validation = data.validation ?? {
2782
2954
  inputs: [],
2783
- outputs: []};
2955
+ outputs: [],
2956
+ issues: [],
2957
+ };
2784
2958
  const isRunning = !!status.activeRuns;
2785
2959
  const pct = Math.round(Math.max(0, Math.min(1, Number(status.progress) || 0)) * 100);
2786
- 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 }) => {
2787
- const vIssues = (kind === "input" ? validation.inputs : validation.outputs).filter((v) => v.handle === id);
2788
- const hasAny = vIssues.length > 0;
2789
- const hasErr = vIssues.some((v) => v.level === "error");
2790
- 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"));
2791
- }, 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 }) => {
2792
2966
  const entries = kind === "input" ? inputEntries : outputEntries;
2793
2967
  const entry = entries.find((e) => e.id === handleId);
2794
2968
  if (!entry)
@@ -3394,9 +3568,9 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3394
3568
  state: "local",
3395
3569
  });
3396
3570
  const selectedNode = def.nodes.find((n) => n.nodeId === selectedNodeId);
3397
- const selectedDesc = selectedNode
3398
- ? registry.nodes.get(selectedNode.typeId)
3399
- : undefined;
3571
+ const effectiveHandles = selectedNode
3572
+ ? computeEffectiveHandles(selectedNode, registry)
3573
+ : { inputs: {}, outputs: {}, inputDefaults: {} };
3400
3574
  const [exampleState, setExampleState] = React.useState(example ?? "");
3401
3575
  const defaultExamples = React.useMemo(() => [
3402
3576
  {
@@ -3684,7 +3858,12 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3684
3858
  const isLinked = def.edges.some((e) => e.target.nodeId === selectedNodeId && e.target.handle === handle);
3685
3859
  if (isLinked)
3686
3860
  return;
3687
- const typeId = selectedDesc?.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);
3688
3867
  let value = raw;
3689
3868
  const parseArray = (s, map) => {
3690
3869
  const str = String(s).trim();
@@ -3761,7 +3940,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3761
3940
  }
3762
3941
  }
3763
3942
  runner.setInput(selectedNodeId, handle, value);
3764
- }, [selectedNodeId, def.edges, selectedDesc, runner]);
3943
+ }, [selectedNodeId, def.edges, effectiveHandles, runner]);
3765
3944
  const setInput = React.useMemo(() => {
3766
3945
  if (overrides?.setInput) {
3767
3946
  return overrides.setInput(baseSetInput, {
@@ -3893,7 +4072,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3893
4072
  const message = err instanceof Error ? err.message : String(err);
3894
4073
  alert(message);
3895
4074
  }
3896
- }, 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 })] })] }));
3897
4076
  }
3898
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, }) {
3899
4078
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());
@@ -3989,9 +4168,14 @@ exports.WorkbenchCanvas = WorkbenchCanvas;
3989
4168
  exports.WorkbenchContext = WorkbenchContext;
3990
4169
  exports.WorkbenchProvider = WorkbenchProvider;
3991
4170
  exports.WorkbenchStudio = WorkbenchStudio;
4171
+ exports.computeEffectiveHandles = computeEffectiveHandles;
4172
+ exports.countVisibleHandles = countVisibleHandles;
4173
+ exports.estimateNodeSize = estimateNodeSize;
3992
4174
  exports.formatDataUrlAsLabel = formatDataUrlAsLabel;
3993
4175
  exports.formatDeclaredTypeSignature = formatDeclaredTypeSignature;
4176
+ exports.getHandleClassName = getHandleClassName;
3994
4177
  exports.getNodeBorderClassNames = getNodeBorderClassNames;
4178
+ exports.layoutNode = layoutNode;
3995
4179
  exports.preformatValueForDisplay = preformatValueForDisplay;
3996
4180
  exports.prettyHandle = prettyHandle;
3997
4181
  exports.resolveOutputDisplay = resolveOutputDisplay;