@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/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 {
@@ -341,7 +341,12 @@ class AbstractGraphRunner {
341
341
  setInput(nodeId, handle, value) {
342
342
  if (!this.stagedInputs[nodeId])
343
343
  this.stagedInputs[nodeId] = {};
344
- this.stagedInputs[nodeId][handle] = value;
344
+ if (value === undefined) {
345
+ delete this.stagedInputs[nodeId][handle];
346
+ }
347
+ else {
348
+ this.stagedInputs[nodeId][handle] = value;
349
+ }
345
350
  if (this.engine) {
346
351
  this.engine.setInput(nodeId, handle, value);
347
352
  }
@@ -359,7 +364,14 @@ class AbstractGraphRunner {
359
364
  return;
360
365
  if (!this.stagedInputs[nodeId])
361
366
  this.stagedInputs[nodeId] = {};
362
- Object.assign(this.stagedInputs[nodeId], inputs);
367
+ for (const [handle, value] of Object.entries(inputs)) {
368
+ if (value === undefined) {
369
+ delete this.stagedInputs[nodeId][handle];
370
+ }
371
+ else {
372
+ this.stagedInputs[nodeId][handle] = value;
373
+ }
374
+ }
363
375
  if (this.engine) {
364
376
  // Running: set all inputs
365
377
  this.engine.setInputs(nodeId, inputs);
@@ -588,8 +600,200 @@ class LocalGraphRunner extends AbstractGraphRunner {
588
600
  }
589
601
 
590
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
+ }
591
794
  constructor(registry, backend) {
592
795
  super(registry, backend);
796
+ this.disposed = false;
593
797
  this.valueCache = new Map();
594
798
  this.listenersBound = false;
595
799
  this.registryFetched = false;
@@ -598,8 +802,8 @@ class RemoteGraphRunner extends AbstractGraphRunner {
598
802
  this.INITIAL_RETRY_DELAY_MS = 1000; // 1 second
599
803
  // Auto-handle registry-changed invalidations from remote
600
804
  // We listen on invalidate and if reason matches, we rehydrate registry and emit a registry event
601
- this.ensureRemoteRunner().then(async (runner) => {
602
- const eng = runner.getEngine();
805
+ this.ensureClient().then(async (client) => {
806
+ const eng = client.getEngine();
603
807
  if (!this.listenersBound) {
604
808
  eng.on("invalidate", async (e) => {
605
809
  if (e.reason === "registry-changed") {
@@ -663,9 +867,9 @@ class RemoteGraphRunner extends AbstractGraphRunner {
663
867
  }
664
868
  update(def) {
665
869
  // Remote: forward update; ignore errors (fire-and-forget)
666
- this.ensureRemoteRunner().then(async (runner) => {
870
+ this.ensureClient().then(async (client) => {
667
871
  try {
668
- await runner.update(def);
872
+ await client.update(def);
669
873
  this.emit("invalidate", { reason: "graph-updated" });
670
874
  this.lastDef = def;
671
875
  }
@@ -675,14 +879,14 @@ class RemoteGraphRunner extends AbstractGraphRunner {
675
879
  launch(def, opts) {
676
880
  super.launch(def, opts);
677
881
  // Remote: build remotely then launch
678
- this.ensureRemoteRunner().then(async (runner) => {
679
- await runner.build(def);
882
+ this.ensureClient().then(async (client) => {
883
+ await client.build(def);
680
884
  // Signal UI after remote build as well
681
885
  this.emit("invalidate", { reason: "graph-built" });
682
886
  this.lastDef = def;
683
887
  // Hydrate current remote inputs/outputs (including defaults) into cache
684
888
  try {
685
- const snap = await runner.snapshot();
889
+ const snap = await client.snapshot();
686
890
  for (const [nodeId, map] of Object.entries(snap.inputs || {})) {
687
891
  for (const [handle, value] of Object.entries(map || {})) {
688
892
  this.valueCache.set(`${nodeId}.${handle}`, {
@@ -705,7 +909,47 @@ class RemoteGraphRunner extends AbstractGraphRunner {
705
909
  catch {
706
910
  console.error("Failed to hydrate remote inputs/outputs");
707
911
  }
708
- 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();
709
953
  if (!this.listenersBound) {
710
954
  eng.on("value", (e) => {
711
955
  this.valueCache.set(`${e.nodeId}.${e.handle}`, {
@@ -739,26 +983,26 @@ class RemoteGraphRunner extends AbstractGraphRunner {
739
983
  console.warn("Unsupported operation for remote runner");
740
984
  }
741
985
  triggerExternal(nodeId, event) {
742
- this.ensureRemoteRunner().then(async (runner) => {
986
+ this.ensureClient().then(async (client) => {
743
987
  try {
744
- await runner.getEngine().triggerExternal(nodeId, event);
988
+ await client.getEngine().triggerExternal(nodeId, event);
745
989
  }
746
990
  catch { }
747
991
  });
748
992
  }
749
993
  async coerce(from, to, value) {
750
- const runner = await this.ensureRemoteRunner();
994
+ const client = await this.ensureClient();
751
995
  try {
752
- return await runner.coerce(from, to, value);
996
+ return await client.coerce(from, to, value);
753
997
  }
754
998
  catch {
755
999
  return value;
756
1000
  }
757
1001
  }
758
1002
  async snapshotFull() {
759
- const runner = await this.ensureRemoteRunner();
1003
+ const client = await this.ensureClient();
760
1004
  try {
761
- return await runner.snapshotFull();
1005
+ return await client.snapshotFull();
762
1006
  }
763
1007
  catch {
764
1008
  return { def: undefined, environment: {}, inputs: {}, outputs: {} };
@@ -766,17 +1010,17 @@ class RemoteGraphRunner extends AbstractGraphRunner {
766
1010
  }
767
1011
  async applySnapshotFull(payload) {
768
1012
  // Hydrate local cache first so UI can display values immediately
769
- this.hydrateCacheFromSnapshot(payload);
1013
+ this.hydrateSnapshotFull(payload);
770
1014
  // Then sync with backend
771
- const runner = await this.ensureRemoteRunner();
772
- await runner.applySnapshotFull(payload);
1015
+ const client = await this.ensureClient();
1016
+ await client.applySnapshotFull(payload);
773
1017
  }
774
1018
  /**
775
1019
  * Hydrates the local valueCache from a snapshot and emits value events.
776
1020
  * This ensures the UI can display inputs/outputs immediately without waiting
777
1021
  * for value events from the remote backend.
778
1022
  */
779
- hydrateCacheFromSnapshot(snapshot) {
1023
+ hydrateSnapshotFull(snapshot) {
780
1024
  // Hydrate inputs
781
1025
  for (const [nodeId, map] of Object.entries(snapshot.inputs || {})) {
782
1026
  for (const [handle, value] of Object.entries(map || {})) {
@@ -799,20 +1043,25 @@ class RemoteGraphRunner extends AbstractGraphRunner {
799
1043
  }
800
1044
  }
801
1045
  setEnvironment(env, opts) {
802
- const t = this.transport;
803
- if (!t)
804
- return;
805
- t.request({
806
- message: {
807
- type: "SetEnvironment",
808
- payload: { environment: env, merge: opts?.merge },
809
- },
810
- }).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
+ }
811
1058
  }
812
1059
  getEnvironment() {
813
- // Fetch from remote via lightweight command
814
- // Note: returns undefined synchronously; callers needing value should use snapshotFull or call runner directly
815
- // 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.
816
1065
  return undefined;
817
1066
  }
818
1067
  getOutputs(def) {
@@ -876,183 +1125,37 @@ class RemoteGraphRunner extends AbstractGraphRunner {
876
1125
  return out;
877
1126
  }
878
1127
  dispose() {
1128
+ // Idempotent: allow multiple calls safely
1129
+ if (this.disposed)
1130
+ return;
1131
+ this.disposed = true;
879
1132
  super.dispose();
880
- this.runner = undefined;
881
- 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;
882
1147
  this.registryFetched = false; // Reset so registry is fetched again on reconnect
883
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
+ }
884
1154
  this.emit("transport", {
885
1155
  state: "disconnected",
886
1156
  kind: this.backend.kind,
887
1157
  });
888
1158
  }
889
- /**
890
- * Fetch full registry description from remote and register it locally.
891
- * Called automatically on first connection with retry mechanism.
892
- */
893
- async fetchRegistry(runner, attempt = 1) {
894
- if (this.registryFetching) {
895
- // Already fetching, don't start another fetch
896
- return;
897
- }
898
- this.registryFetching = true;
899
- try {
900
- const desc = await runner.describeRegistry();
901
- // Register types
902
- for (const t of desc.types) {
903
- if (t.options) {
904
- this.registry.registerEnum({
905
- id: t.id,
906
- options: t.options,
907
- bakeTarget: t.bakeTarget,
908
- });
909
- }
910
- else {
911
- if (!this.registry.types.has(t.id)) {
912
- this.registry.registerType({
913
- id: t.id,
914
- displayName: t.displayName,
915
- validate: (_v) => true,
916
- bakeTarget: t.bakeTarget,
917
- });
918
- }
919
- }
920
- }
921
- // Register categories
922
- for (const c of desc.categories || []) {
923
- if (!this.registry.categories.has(c.id)) {
924
- // Create placeholder category descriptor
925
- const category = {
926
- id: c.id,
927
- displayName: c.displayName,
928
- createRuntime: () => ({
929
- async onInputsChanged() { },
930
- }),
931
- policy: { asyncConcurrency: "switch" },
932
- };
933
- this.registry.categories.register(category);
934
- }
935
- }
936
- // Register coercions
937
- for (const c of desc.coercions) {
938
- if (c.async) {
939
- this.registry.registerAsyncCoercion(c.from, c.to, async (v) => v, {
940
- nonTransitive: c.nonTransitive,
941
- });
942
- }
943
- else {
944
- this.registry.registerCoercion(c.from, c.to, (v) => v, {
945
- nonTransitive: c.nonTransitive,
946
- });
947
- }
948
- }
949
- // Register nodes
950
- for (const n of desc.nodes) {
951
- if (!this.registry.nodes.has(n.id)) {
952
- this.registry.registerNode({
953
- id: n.id,
954
- categoryId: n.categoryId,
955
- displayName: n.displayName,
956
- inputs: n.inputs || {},
957
- outputs: n.outputs || {},
958
- impl: () => { },
959
- });
960
- }
961
- }
962
- this.registryFetched = true;
963
- this.registryFetching = false;
964
- this.emit("registry", this.registry);
965
- }
966
- catch (err) {
967
- this.registryFetching = false;
968
- const error = err instanceof Error ? err : new Error(String(err));
969
- // Retry with exponential backoff if attempts remaining
970
- if (attempt < this.MAX_REGISTRY_FETCH_ATTEMPTS) {
971
- const delayMs = this.INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
972
- console.warn(`Failed to fetch registry (attempt ${attempt}/${this.MAX_REGISTRY_FETCH_ATTEMPTS}), retrying in ${delayMs}ms...`, error);
973
- // Emit error event for UI feedback
974
- this.emit("error", {
975
- kind: "registry",
976
- message: `Registry fetch failed (attempt ${attempt}/${this.MAX_REGISTRY_FETCH_ATTEMPTS}), retrying...`,
977
- err: error,
978
- attempt,
979
- maxAttempts: this.MAX_REGISTRY_FETCH_ATTEMPTS,
980
- });
981
- // Retry after delay
982
- setTimeout(() => {
983
- this.fetchRegistry(runner, attempt + 1).catch(() => {
984
- // Final failure handled below
985
- });
986
- }, delayMs);
987
- }
988
- else {
989
- // Max attempts reached, emit final error
990
- console.error(`Failed to fetch registry after ${this.MAX_REGISTRY_FETCH_ATTEMPTS} attempts:`, error);
991
- this.emit("error", {
992
- kind: "registry",
993
- message: `Failed to fetch registry after ${this.MAX_REGISTRY_FETCH_ATTEMPTS} attempts. Please check your connection and try refreshing.`,
994
- err: error,
995
- attempt: this.MAX_REGISTRY_FETCH_ATTEMPTS,
996
- maxAttempts: this.MAX_REGISTRY_FETCH_ATTEMPTS,
997
- });
998
- }
999
- }
1000
- }
1001
- // Ensure remote transport/runner
1002
- async ensureRemoteRunner() {
1003
- if (this.runner)
1004
- return this.runner;
1005
- let transport;
1006
- const kind = this.backend.kind;
1007
- const backend = this.backend;
1008
- const connectOptions = backend.connectOptions;
1009
- this.emit("transport", { state: "connecting", kind });
1010
- if (backend.kind === "remote-http") {
1011
- if (!HttpPollingTransport)
1012
- throw new Error("HttpPollingTransport not available");
1013
- transport = new HttpPollingTransport(backend.baseUrl);
1014
- await transport.connect(connectOptions);
1015
- }
1016
- else if (backend.kind === "remote-ws") {
1017
- if (!WebSocketTransport)
1018
- throw new Error("WebSocketTransport not available");
1019
- transport = new WebSocketTransport(backend.url);
1020
- await transport.connect(connectOptions);
1021
- }
1022
- else {
1023
- throw new Error("Remote backend not configured");
1024
- }
1025
- // Subscribe to custom events if handler provided
1026
- if (backend.onCustomEvent) {
1027
- transport.subscribe((event) => {
1028
- // Filter out standard runtime events, pass others to custom handler
1029
- const msg = event.message;
1030
- if (msg && typeof msg === "object" && "type" in msg) {
1031
- const type = msg.type;
1032
- // Standard runtime events: stats, value, error, invalidate
1033
- // Custom events are anything else (e.g., flow-opened, flow-latest)
1034
- if (!["stats", "value", "error", "invalidate"].includes(type)) {
1035
- backend.onCustomEvent?.(event);
1036
- }
1037
- }
1038
- });
1039
- }
1040
- const runner = new RemoteRunner(transport);
1041
- this.runner = runner;
1042
- this.transport = transport;
1043
- this.valueCache.clear();
1044
- this.listenersBound = false;
1045
- this.emit("transport", { state: "connected", kind });
1046
- // Auto-fetch registry on first connection (only once)
1047
- if (!this.registryFetched && !this.registryFetching) {
1048
- // Log loading state (UI can listen to transport status for loading indication)
1049
- console.info("Loading registry from remote...");
1050
- this.fetchRegistry(runner).catch(() => {
1051
- // Error handling is done inside fetchRegistry
1052
- });
1053
- }
1054
- return runner;
1055
- }
1056
1159
  }
1057
1160
 
1058
1161
  function useWorkbenchBridge(wb) {
@@ -1720,6 +1823,33 @@ function getNodeBorderClassNames(args) {
1720
1823
  : "";
1721
1824
  return [borderWidth, borderStyle, borderColor, ring].join(" ").trim();
1722
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
+ }
1723
1853
 
1724
1854
  const WorkbenchContext = createContext(null);
1725
1855
  function useWorkbenchContext() {
@@ -1736,14 +1866,19 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
1736
1866
  const clearEvents = useCallback(() => setEvents([]), []);
1737
1867
  const [systemErrors, setSystemErrors] = useState([]);
1738
1868
  const [registryErrors, setRegistryErrors] = useState([]);
1869
+ const [inputValidationErrors, setInputValidationErrors] = useState([]);
1739
1870
  const clearSystemErrors = useCallback(() => setSystemErrors([]), []);
1740
1871
  const clearRegistryErrors = useCallback(() => setRegistryErrors([]), []);
1872
+ const clearInputValidationErrors = useCallback(() => setInputValidationErrors([]), []);
1741
1873
  const removeSystemError = useCallback((index) => {
1742
1874
  setSystemErrors((prev) => prev.filter((_, idx) => idx !== index));
1743
1875
  }, []);
1744
1876
  const removeRegistryError = useCallback((index) => {
1745
1877
  setRegistryErrors((prev) => prev.filter((_, idx) => idx !== index));
1746
1878
  }, []);
1879
+ const removeInputValidationError = useCallback((index) => {
1880
+ setInputValidationErrors((prev) => prev.filter((_, idx) => idx !== index));
1881
+ }, []);
1747
1882
  // Fallback progress animation: drive progress to 100% over ~2 minutes
1748
1883
  const FALLBACK_TOTAL_MS = 2 * 60 * 1000;
1749
1884
  const [fallbackStarts, setFallbackStarts] = useState({});
@@ -1799,7 +1934,8 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
1799
1934
  const out = {};
1800
1935
  // Local: runtimeTypeId is not stored; derive from typed wrapper in outputsMap
1801
1936
  for (const n of def.nodes) {
1802
- const outputsDecl = registry.nodes.get(n.typeId)?.outputs ?? {};
1937
+ const effectiveHandles = computeEffectiveHandles(n, registry);
1938
+ const outputsDecl = effectiveHandles.outputs;
1803
1939
  const handles = Object.keys(outputsDecl);
1804
1940
  const cur = {};
1805
1941
  for (const h of handles) {
@@ -1960,10 +2096,13 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
1960
2096
  const offRunnerValue = runner.on("value", (e) => {
1961
2097
  if (e?.io === "input") {
1962
2098
  const nodeId = e?.nodeId;
2099
+ const handle = e?.handle;
1963
2100
  setNodeStatus((s) => ({
1964
2101
  ...s,
1965
2102
  [nodeId]: { ...s[nodeId], invalidated: true },
1966
2103
  }));
2104
+ // Clear validation errors for this input when a valid value is set
2105
+ setInputValidationErrors((prev) => prev.filter((err) => !(err.nodeId === nodeId && err.handle === handle)));
1967
2106
  }
1968
2107
  return add("runner", "value")(e);
1969
2108
  });
@@ -1972,6 +2111,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
1972
2111
  const nodeError = e;
1973
2112
  const registryError = e;
1974
2113
  const systemError = e;
2114
+ const inputValidationError = e;
1975
2115
  if (edgeError.kind === "edge-convert") {
1976
2116
  const edgeId = edgeError.edgeId;
1977
2117
  setEdgeStatus((s) => ({
@@ -2007,6 +2147,18 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2007
2147
  return [...prev, registryError];
2008
2148
  });
2009
2149
  }
2150
+ else if (inputValidationError.kind === "input-validation") {
2151
+ // Track input validation errors for UI display
2152
+ setInputValidationErrors((prev) => {
2153
+ // Avoid duplicates by checking nodeId, handle, and typeId
2154
+ if (prev.some((err) => err.nodeId === inputValidationError.nodeId &&
2155
+ err.handle === inputValidationError.handle &&
2156
+ err.typeId === inputValidationError.typeId)) {
2157
+ return prev;
2158
+ }
2159
+ return [...prev, inputValidationError];
2160
+ });
2161
+ }
2010
2162
  else if (systemError.kind === "system") {
2011
2163
  // Track custom errors for UI display
2012
2164
  setSystemErrors((prev) => {
@@ -2130,7 +2282,14 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2130
2282
  }
2131
2283
  return add("runner", "stats")(s);
2132
2284
  });
2133
- const offWbGraphChanged = wb.on("graphChanged", add("workbench", "graphChanged"));
2285
+ const offWbGraphChanged = wb.on("graphChanged", (event) => {
2286
+ // Clear validation errors for removed nodes
2287
+ if (event.change?.type === "removeNode") {
2288
+ const removedNodeId = event.change.nodeId;
2289
+ setInputValidationErrors((prev) => prev.filter((err) => err.nodeId !== removedNodeId));
2290
+ }
2291
+ return add("workbench", "graphChanged")(event);
2292
+ });
2134
2293
  const offWbGraphUiChanged = wb.on("graphUiChanged", add("workbench", "graphUiChanged"));
2135
2294
  const offWbValidationChanged = wb.on("validationChanged", add("workbench", "validationChanged"));
2136
2295
  // Ensure newly added nodes start as invalidated until first evaluation
@@ -2300,10 +2459,13 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2300
2459
  clearEvents,
2301
2460
  systemErrors,
2302
2461
  registryErrors,
2462
+ inputValidationErrors,
2303
2463
  clearSystemErrors,
2304
2464
  clearRegistryErrors,
2465
+ clearInputValidationErrors,
2305
2466
  removeSystemError,
2306
2467
  removeRegistryError,
2468
+ removeInputValidationError,
2307
2469
  isRunning,
2308
2470
  engineKind,
2309
2471
  start,
@@ -2328,10 +2490,13 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2328
2490
  valuesTick,
2329
2491
  systemErrors,
2330
2492
  registryErrors,
2493
+ inputValidationErrors,
2331
2494
  clearSystemErrors,
2332
2495
  clearRegistryErrors,
2496
+ clearInputValidationErrors,
2333
2497
  removeSystemError,
2334
2498
  removeRegistryError,
2499
+ removeInputValidationError,
2335
2500
  inputsMap,
2336
2501
  inputDefaultsMap,
2337
2502
  outputsMap,
@@ -2407,7 +2572,7 @@ function DebugEvents({ autoScroll, onAutoScrollChange, hideWorkbench, onHideWork
2407
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}`))) })] }));
2408
2573
  }
2409
2574
 
2410
- function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHideWorkbenchChange, toString, toElement, contextPanel, setInput, }) {
2575
+ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHideWorkbenchChange, toString, contextPanel, setInput, }) {
2411
2576
  const safeToString = (typeId, value) => {
2412
2577
  try {
2413
2578
  if (typeof toString === "function") {
@@ -2419,7 +2584,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2419
2584
  return String(value ?? "");
2420
2585
  }
2421
2586
  };
2422
- const { registry, def, selectedNodeId, selectedEdgeId, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, nodeStatus, edgeStatus, validationByNode, validationByEdge, validationGlobal, valuesTick, updateEdgeType, systemErrors, registryErrors, clearSystemErrors, clearRegistryErrors, removeSystemError, removeRegistryError, } = useWorkbenchContext();
2587
+ 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();
2423
2588
  const nodeValidationIssues = validationByNode.issues;
2424
2589
  const edgeValidationIssues = validationByEdge.issues;
2425
2590
  const nodeValidationHandles = validationByNode;
@@ -2530,7 +2695,7 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2530
2695
  }
2531
2696
  catch { }
2532
2697
  };
2533
- return (jsxs("div", { className: `${widthClass} border-l border-gray-300 p-3 flex flex-col h-full min-h-0 overflow-auto`, children: [jsxs("div", { className: "flex-1 overflow-auto", children: [contextPanel && jsx("div", { className: "mb-2", children: contextPanel }), systemErrors.length > 0 && (jsxs("div", { className: "mb-2 space-y-1", children: [systemErrors.map((err, i) => (jsxs("div", { className: "text-xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 flex items-start justify-between gap-2", children: [jsxs("div", { className: "flex-1", children: [jsx("div", { className: "font-semibold", children: err.code ? `Error ${err.code}` : "Error" }), jsx("div", { className: "break-words", children: err.message })] }), jsx("button", { className: "text-red-500 hover:text-red-700 text-[10px] px-1", onClick: () => removeSystemError(i), title: "Dismiss", children: jsx(XIcon, { size: 10 }) })] }, i))), systemErrors.length > 1 && (jsx("button", { className: "text-xs text-red-600 hover:text-red-800 underline", onClick: clearSystemErrors, children: "Clear all" }))] })), registryErrors.length > 0 && (jsxs("div", { className: "mb-2 space-y-1", children: [registryErrors.map((err, i) => (jsxs("div", { className: "text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 flex items-start justify-between gap-2", children: [jsxs("div", { className: "flex-1", children: [jsx("div", { className: "font-semibold", children: "Registry Error" }), jsx("div", { className: "break-words", children: err.message }), err.attempt && err.maxAttempts && (jsxs("div", { className: "text-[10px] text-amber-600 mt-1", children: ["Attempt ", err.attempt, " of ", err.maxAttempts] }))] }), jsx("button", { className: "text-amber-500 hover:text-amber-700 text-[10px] px-1", onClick: () => removeRegistryError(i), title: "Dismiss", children: jsx(XIcon, { size: 10 }) })] }, i))), registryErrors.length > 1 && (jsx("button", { className: "text-xs text-amber-600 hover:text-amber-800 underline", onClick: clearRegistryErrors, children: "Clear all" }))] })), jsx("div", { className: "font-semibold mb-2", children: "Inspector" }), jsxs("div", { className: "text-xs text-gray-500 mb-2", children: ["valuesTick: ", valuesTick] }), jsx("div", { className: "flex-1", children: !selectedNode && !selectedEdge ? (jsxs("div", { children: [jsx("div", { className: "text-gray-500", children: "Select a node or edge." }), globalValidationIssues && globalValidationIssues.length > 0 && (jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsx("ul", { className: "list-disc ml-4", children: globalValidationIssues.map((m, i) => (jsxs("li", { className: "flex items-center gap-1", children: [jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsx("span", { children: `${m.code}: ${m.message}` }), !!m.data?.edgeId && (jsx("button", { className: "ml-2 text-[10px] px-1 py-[2px] border border-red-300 rounded text-red-700 hover:bg-red-50", onClick: (e) => {
2698
+ return (jsxs("div", { className: `${widthClass} border-l border-gray-300 p-3 flex flex-col h-full min-h-0 overflow-auto`, children: [jsxs("div", { className: "flex-1 overflow-auto", children: [contextPanel && jsx("div", { className: "mb-2", children: contextPanel }), inputValidationErrors.length > 0 && (jsxs("div", { className: "mb-2 space-y-1", children: [inputValidationErrors.map((err, i) => (jsxs("div", { className: "text-xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 flex items-start justify-between gap-2", children: [jsxs("div", { className: "flex-1", children: [jsx("div", { className: "font-semibold", children: "Input Validation Error" }), jsx("div", { className: "break-words", children: err.message }), jsxs("div", { className: "text-[10px] text-red-600 mt-1", children: [err.nodeId, ".", err.handle, " (type: ", err.typeId, ")"] })] }), jsx("button", { className: "text-red-500 hover:text-red-700 text-[10px] px-1", onClick: () => removeInputValidationError(i), title: "Dismiss", children: jsx(XIcon, { size: 10 }) })] }, i))), inputValidationErrors.length > 1 && (jsx("button", { className: "text-xs text-red-600 hover:text-red-800 underline", onClick: clearInputValidationErrors, children: "Clear all" }))] })), systemErrors.length > 0 && (jsxs("div", { className: "mb-2 space-y-1", children: [systemErrors.map((err, i) => (jsxs("div", { className: "text-xs text-red-700 bg-red-50 border border-red-200 rounded px-2 py-1 flex items-start justify-between gap-2", children: [jsxs("div", { className: "flex-1", children: [jsx("div", { className: "font-semibold", children: err.code ? `Error ${err.code}` : "Error" }), jsx("div", { className: "break-words", children: err.message })] }), jsx("button", { className: "text-red-500 hover:text-red-700 text-[10px] px-1", onClick: () => removeSystemError(i), title: "Dismiss", children: jsx(XIcon, { size: 10 }) })] }, i))), systemErrors.length > 1 && (jsx("button", { className: "text-xs text-red-600 hover:text-red-800 underline", onClick: clearSystemErrors, children: "Clear all" }))] })), registryErrors.length > 0 && (jsxs("div", { className: "mb-2 space-y-1", children: [registryErrors.map((err, i) => (jsxs("div", { className: "text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 flex items-start justify-between gap-2", children: [jsxs("div", { className: "flex-1", children: [jsx("div", { className: "font-semibold", children: "Registry Error" }), jsx("div", { className: "break-words", children: err.message }), err.attempt && err.maxAttempts && (jsxs("div", { className: "text-[10px] text-amber-600 mt-1", children: ["Attempt ", err.attempt, " of ", err.maxAttempts] }))] }), jsx("button", { className: "text-amber-500 hover:text-amber-700 text-[10px] px-1", onClick: () => removeRegistryError(i), title: "Dismiss", children: jsx(XIcon, { size: 10 }) })] }, i))), registryErrors.length > 1 && (jsx("button", { className: "text-xs text-amber-600 hover:text-amber-800 underline", onClick: clearRegistryErrors, children: "Clear all" }))] })), jsx("div", { className: "font-semibold mb-2", children: "Inspector" }), jsxs("div", { className: "text-xs text-gray-500 mb-2", children: ["valuesTick: ", valuesTick] }), jsx("div", { className: "flex-1", children: !selectedNode && !selectedEdge ? (jsxs("div", { children: [jsx("div", { className: "text-gray-500", children: "Select a node or edge." }), globalValidationIssues && globalValidationIssues.length > 0 && (jsxs("div", { className: "mt-2 text-xs bg-red-50 border border-red-200 rounded px-2 py-1", children: [jsx("div", { className: "font-semibold mb-1", children: "Validation" }), jsx("ul", { className: "list-disc ml-4", children: globalValidationIssues.map((m, i) => (jsxs("li", { className: "flex items-center gap-1", children: [jsx(IssueBadge, { level: m.level, size: 24, className: "w-6 h-6" }), jsx("span", { children: `${m.code}: ${m.message}` }), !!m.data?.edgeId && (jsx("button", { className: "ml-2 text-[10px] px-1 py-[2px] border border-red-300 rounded text-red-700 hover:bg-red-50", onClick: (e) => {
2534
2699
  e.stopPropagation();
2535
2700
  deleteEdgeById(m.data?.edgeId);
2536
2701
  }, title: "Delete referenced edge", children: "Delete edge" }))] }, i))) })] }))] })) : selectedEdge ? (jsxs("div", { children: [jsxs("div", { className: "mb-2", children: [jsxs("div", { children: ["Edge: ", selectedEdge.id] }), jsxs("div", { children: [selectedEdge.source.nodeId, ".", selectedEdge.source.handle, " \u2192", " ", selectedEdge.target.nodeId, ".", selectedEdge.target.handle] }), (() => {
@@ -2568,13 +2733,20 @@ function Inspector({ debug, autoScroll, hideWorkbench, onAutoScrollChange, onHid
2568
2733
  const current = nodeInputs[h];
2569
2734
  const hasValue = current !== undefined && current !== null;
2570
2735
  const value = drafts[h] ?? safeToString(typeId, current);
2571
- const displayValue = hasValue ? value : "";
2736
+ const displayValue = value;
2572
2737
  const placeholder = hasDefault ? defaultStr : undefined;
2573
2738
  const onChangeText = (text) => setDrafts((d) => ({ ...d, [h]: text }));
2574
2739
  const commit = () => {
2575
2740
  const draft = drafts[h];
2576
2741
  if (draft === undefined)
2577
2742
  return;
2743
+ // Only commit if draft differs from current value
2744
+ const currentDisplay = safeToString(typeId, current);
2745
+ if (draft === currentDisplay) {
2746
+ // No change, just sync originals without calling setInput
2747
+ setOriginals((o) => ({ ...o, [h]: draft }));
2748
+ return;
2749
+ }
2578
2750
  setInput(h, draft);
2579
2751
  setOriginals((o) => ({ ...o, [h]: draft }));
2580
2752
  };
@@ -2778,15 +2950,17 @@ function DefaultNodeContent({ data, isConnectable, }) {
2778
2950
  const status = data.status ?? { activeRuns: 0 };
2779
2951
  const validation = data.validation ?? {
2780
2952
  inputs: [],
2781
- outputs: []};
2953
+ outputs: [],
2954
+ issues: [],
2955
+ };
2782
2956
  const isRunning = !!status.activeRuns;
2783
2957
  const pct = Math.round(Math.max(0, Math.min(1, Number(status.progress) || 0)) * 100);
2784
- 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 }) => {
2785
- const vIssues = (kind === "input" ? validation.inputs : validation.outputs).filter((v) => v.handle === id);
2786
- const hasAny = vIssues.length > 0;
2787
- const hasErr = vIssues.some((v) => v.level === "error");
2788
- 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"));
2789
- }, 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 }) => {
2790
2964
  const entries = kind === "input" ? inputEntries : outputEntries;
2791
2965
  const entry = entries.find((e) => e.id === handleId);
2792
2966
  if (!entry)
@@ -3392,9 +3566,9 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3392
3566
  state: "local",
3393
3567
  });
3394
3568
  const selectedNode = def.nodes.find((n) => n.nodeId === selectedNodeId);
3395
- const selectedDesc = selectedNode
3396
- ? registry.nodes.get(selectedNode.typeId)
3397
- : undefined;
3569
+ const effectiveHandles = selectedNode
3570
+ ? computeEffectiveHandles(selectedNode, registry)
3571
+ : { inputs: {}, outputs: {}, inputDefaults: {} };
3398
3572
  const [exampleState, setExampleState] = useState(example ?? "");
3399
3573
  const defaultExamples = useMemo(() => [
3400
3574
  {
@@ -3682,7 +3856,12 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3682
3856
  const isLinked = def.edges.some((e) => e.target.nodeId === selectedNodeId && e.target.handle === handle);
3683
3857
  if (isLinked)
3684
3858
  return;
3685
- const typeId = selectedDesc?.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);
3686
3865
  let value = raw;
3687
3866
  const parseArray = (s, map) => {
3688
3867
  const str = String(s).trim();
@@ -3759,7 +3938,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3759
3938
  }
3760
3939
  }
3761
3940
  runner.setInput(selectedNodeId, handle, value);
3762
- }, [selectedNodeId, def.edges, selectedDesc, runner]);
3941
+ }, [selectedNodeId, def.edges, effectiveHandles, runner]);
3763
3942
  const setInput = useMemo(() => {
3764
3943
  if (overrides?.setInput) {
3765
3944
  return overrides.setInput(baseSetInput, {
@@ -3891,7 +4070,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3891
4070
  const message = err instanceof Error ? err.message : String(err);
3892
4071
  alert(message);
3893
4072
  }
3894
- }, 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 })] })] }));
3895
4074
  }
3896
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, }) {
3897
4076
  const [registry, setRegistry] = useState(createSimpleGraphRegistry());
@@ -3972,5 +4151,5 @@ function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, bac
3972
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 }) }));
3973
4152
  }
3974
4153
 
3975
- 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 };
3976
4155
  //# sourceMappingURL=index.js.map