@flowdrop/flowdrop 1.3.0 → 1.5.0

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 (114) hide show
  1. package/README.md +68 -24
  2. package/dist/adapters/WorkflowAdapter.js +2 -22
  3. package/dist/adapters/agentspec/autoLayout.d.ts +51 -5
  4. package/dist/adapters/agentspec/autoLayout.js +120 -23
  5. package/dist/chat/commandClassifier.d.ts +19 -0
  6. package/dist/chat/commandClassifier.js +30 -0
  7. package/dist/chat/index.d.ts +27 -0
  8. package/dist/chat/index.js +32 -0
  9. package/dist/chat/responseParser.d.ts +21 -0
  10. package/dist/chat/responseParser.js +87 -0
  11. package/dist/commands/batch.d.ts +18 -0
  12. package/dist/commands/batch.js +56 -0
  13. package/dist/commands/executor.d.ts +37 -0
  14. package/dist/commands/executor.js +1044 -0
  15. package/dist/commands/index.d.ts +14 -0
  16. package/dist/commands/index.js +17 -0
  17. package/dist/commands/parser.d.ts +16 -0
  18. package/dist/commands/parser.js +278 -0
  19. package/dist/commands/positioner.d.ts +19 -0
  20. package/dist/commands/positioner.js +33 -0
  21. package/dist/commands/storeIntegration.svelte.d.ts +16 -0
  22. package/dist/commands/storeIntegration.svelte.js +67 -0
  23. package/dist/commands/types.d.ts +343 -0
  24. package/dist/commands/types.js +45 -0
  25. package/dist/components/App.svelte +431 -17
  26. package/dist/components/App.svelte.d.ts +10 -0
  27. package/dist/components/CanvasBanner.stories.svelte +6 -2
  28. package/dist/components/CanvasController.svelte +38 -0
  29. package/dist/components/CanvasController.svelte.d.ts +32 -0
  30. package/dist/components/ConfigMappingRow.svelte +130 -0
  31. package/dist/components/ConfigMappingRow.svelte.d.ts +8 -0
  32. package/dist/components/ConfigPanel.svelte +56 -7
  33. package/dist/components/ConfigPanel.svelte.d.ts +2 -0
  34. package/dist/components/FlowDropEdge.svelte +8 -57
  35. package/dist/components/Logo.svelte +14 -14
  36. package/dist/components/LogsSidebar.svelte +5 -5
  37. package/dist/components/Navbar.svelte +58 -10
  38. package/dist/components/Navbar.svelte.d.ts +7 -0
  39. package/dist/components/NodeSidebar.svelte +238 -362
  40. package/dist/components/NodeSwapPicker.svelte +537 -0
  41. package/dist/components/NodeSwapPicker.svelte.d.ts +16 -0
  42. package/dist/components/PortMappingRow.svelte +209 -0
  43. package/dist/components/PortMappingRow.svelte.d.ts +12 -0
  44. package/dist/components/SwapMappingEditor.svelte +550 -0
  45. package/dist/components/SwapMappingEditor.svelte.d.ts +12 -0
  46. package/dist/components/WorkflowEditor.svelte +99 -4
  47. package/dist/components/WorkflowEditor.svelte.d.ts +8 -0
  48. package/dist/components/chat/AIChatPanel.svelte +658 -0
  49. package/dist/components/chat/AIChatPanel.svelte.d.ts +13 -0
  50. package/dist/components/chat/CommandPreview.svelte +184 -0
  51. package/dist/components/chat/CommandPreview.svelte.d.ts +9 -0
  52. package/dist/components/console/CommandConsole.stories.svelte +93 -0
  53. package/dist/components/console/CommandConsole.stories.svelte.d.ts +27 -0
  54. package/dist/components/console/CommandConsole.svelte +259 -0
  55. package/dist/components/console/CommandConsole.svelte.d.ts +11 -0
  56. package/dist/components/console/ConsoleAutocomplete.svelte +139 -0
  57. package/dist/components/console/ConsoleAutocomplete.svelte.d.ts +21 -0
  58. package/dist/components/console/ConsoleInput.svelte +712 -0
  59. package/dist/components/console/ConsoleInput.svelte.d.ts +16 -0
  60. package/dist/components/console/ConsoleOutput.svelte +121 -0
  61. package/dist/components/console/ConsoleOutput.svelte.d.ts +11 -0
  62. package/dist/components/console/formatters.d.ts +26 -0
  63. package/dist/components/console/formatters.js +118 -0
  64. package/dist/components/interrupt/index.d.ts +1 -0
  65. package/dist/components/interrupt/index.js +1 -0
  66. package/dist/components/nodes/SimpleNode.stories.svelte +64 -0
  67. package/dist/components/nodes/SimpleNode.svelte +27 -11
  68. package/dist/components/nodes/SquareNode.stories.svelte +45 -0
  69. package/dist/components/nodes/SquareNode.svelte +27 -11
  70. package/dist/components/nodes/WorkflowNode.stories.svelte +63 -0
  71. package/dist/config/endpoints.d.ts +8 -0
  72. package/dist/config/endpoints.js +5 -0
  73. package/dist/core/index.d.ts +5 -0
  74. package/dist/core/index.js +9 -0
  75. package/dist/editor/index.d.ts +3 -1
  76. package/dist/editor/index.js +4 -2
  77. package/dist/helpers/proximityConnect.js +8 -1
  78. package/dist/helpers/workflowEditorHelper.d.ts +3 -53
  79. package/dist/helpers/workflowEditorHelper.js +13 -228
  80. package/dist/playground/index.d.ts +1 -1
  81. package/dist/playground/index.js +1 -1
  82. package/dist/schemas/v1/workflow.schema.json +107 -22
  83. package/dist/services/chatService.d.ts +65 -0
  84. package/dist/services/chatService.js +131 -0
  85. package/dist/services/historyService.d.ts +6 -4
  86. package/dist/services/historyService.js +21 -6
  87. package/dist/skins/slate.js +16 -0
  88. package/dist/stores/interruptStore.svelte.js +6 -1
  89. package/dist/stores/playgroundStore.svelte.d.ts +1 -1
  90. package/dist/stores/playgroundStore.svelte.js +11 -2
  91. package/dist/stores/portCoordinateStore.svelte.d.ts +4 -0
  92. package/dist/stores/portCoordinateStore.svelte.js +20 -26
  93. package/dist/stores/workflowStore.svelte.d.ts +31 -2
  94. package/dist/stores/workflowStore.svelte.js +84 -64
  95. package/dist/stories/EdgeDecorator.svelte +4 -4
  96. package/dist/styles/base.css +48 -0
  97. package/dist/svelte-app.d.ts +7 -1
  98. package/dist/svelte-app.js +4 -1
  99. package/dist/types/chat.d.ts +63 -0
  100. package/dist/types/chat.js +9 -0
  101. package/dist/types/events.d.ts +28 -2
  102. package/dist/types/events.js +1 -0
  103. package/dist/types/index.d.ts +8 -0
  104. package/dist/types/settings.d.ts +6 -0
  105. package/dist/types/settings.js +3 -0
  106. package/dist/utils/edgeStyling.d.ts +42 -0
  107. package/dist/utils/edgeStyling.js +176 -0
  108. package/dist/utils/nodeIds.d.ts +31 -0
  109. package/dist/utils/nodeIds.js +42 -0
  110. package/dist/utils/nodeSwap.d.ts +221 -0
  111. package/dist/utils/nodeSwap.js +686 -0
  112. package/package.json +6 -1
  113. package/dist/helpers/nodeLayoutHelper.d.ts +0 -14
  114. package/dist/helpers/nodeLayoutHelper.js +0 -19
@@ -0,0 +1,686 @@
1
+ /**
2
+ * Node Swap utilities for FlowDrop
3
+ *
4
+ * Provides logic for swapping a workflow node with a different node type
5
+ * while intelligently remapping compatible port connections.
6
+ *
7
+ * @module utils/nodeSwap
8
+ */
9
+ import { buildHandleId, extractPortId, extractDirection } from "./handleIds.js";
10
+ import { generateNodeId } from "./nodeIds.js";
11
+ /** Error class for swap validation failures. */
12
+ export class SwapValidationError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "SwapValidationError";
16
+ }
17
+ }
18
+ // =========================================================================
19
+ // Dynamic port keys that should never be carried over
20
+ // =========================================================================
21
+ const DYNAMIC_PORT_KEYS = new Set([
22
+ "dynamicInputs",
23
+ "dynamicOutputs",
24
+ "branches",
25
+ ]);
26
+ // =========================================================================
27
+ // Semver comparison
28
+ // =========================================================================
29
+ /**
30
+ * Compare two semver-like version strings.
31
+ * Returns positive if a > b, negative if a < b, 0 if equal.
32
+ *
33
+ * Handles pre-release tags: "2.0.0-beta" < "2.0.0"
34
+ */
35
+ export function compareSemver(a, b) {
36
+ // Split off pre-release tag
37
+ const [aCore, aPre] = a.split("-", 2);
38
+ const [bCore, bPre] = b.split("-", 2);
39
+ const aParts = aCore.split(".").map(Number);
40
+ const bParts = bCore.split(".").map(Number);
41
+ const maxLen = Math.max(aParts.length, bParts.length);
42
+ for (let i = 0; i < maxLen; i++) {
43
+ const av = aParts[i] ?? 0;
44
+ const bv = bParts[i] ?? 0;
45
+ if (av !== bv)
46
+ return av - bv;
47
+ }
48
+ // Core versions are equal — pre-release < release
49
+ if (aPre && !bPre)
50
+ return -1;
51
+ if (!aPre && bPre)
52
+ return 1;
53
+ // Both have pre-release or neither — compare lexicographically
54
+ if (aPre && bPre)
55
+ return aPre.localeCompare(bPre);
56
+ return 0;
57
+ }
58
+ // =========================================================================
59
+ // Config mapping
60
+ // =========================================================================
61
+ /**
62
+ * Map config values from an old node to a new node's schema.
63
+ *
64
+ * - Keys present in both old config and new schema: carry over the old value
65
+ * - Keys only in the new schema: use the schema default or newDefaults
66
+ * - Keys only in the old config: discarded
67
+ * - Dynamic port keys (dynamicInputs, dynamicOutputs, branches): never carried over
68
+ */
69
+ export function mapConfig(oldConfig, newConfigSchema, newDefaults = {}) {
70
+ if (!newConfigSchema?.properties) {
71
+ return { config: {}, carriedOver: [], reset: [] };
72
+ }
73
+ const config = {};
74
+ const carriedOver = [];
75
+ const reset = [];
76
+ for (const key of Object.keys(newConfigSchema.properties)) {
77
+ if (DYNAMIC_PORT_KEYS.has(key))
78
+ continue;
79
+ const schemaProp = newConfigSchema.properties[key];
80
+ const schemaDefault = schemaProp?.default;
81
+ const providedDefault = newDefaults[key];
82
+ if (key in oldConfig) {
83
+ config[key] = oldConfig[key];
84
+ carriedOver.push(key);
85
+ }
86
+ else {
87
+ config[key] = providedDefault !== undefined ? providedDefault : schemaDefault;
88
+ reset.push(key);
89
+ }
90
+ }
91
+ return { config, carriedOver, reset };
92
+ }
93
+ // =========================================================================
94
+ // Port matching
95
+ // =========================================================================
96
+ /**
97
+ * Find the best matching port on the new node for a given old port.
98
+ *
99
+ * Three-pass strategy:
100
+ * 1. Exact port ID match with compatible dataType
101
+ * 2. Port name match (case-insensitive) with compatible dataType
102
+ * 3. First available port with compatible dataType
103
+ */
104
+ function findMatchingPort(oldPort, newPorts, usedPortIds, checker) {
105
+ const available = newPorts.filter((p) => p.type === oldPort.type && !usedPortIds.has(p.id));
106
+ const isCompatible = (a, b) => {
107
+ if (!checker)
108
+ return a.dataType === b.dataType;
109
+ // Check both directions since the port role (input vs output) matters
110
+ if (oldPort.type === "input") {
111
+ // Old port is input → old dataType was the target; check any source can feed into new port
112
+ return checker.areDataTypesCompatible(a.dataType, b.dataType);
113
+ }
114
+ return checker.areDataTypesCompatible(b.dataType, a.dataType);
115
+ };
116
+ // Pass 1: exact ID match
117
+ const idMatch = available.find((p) => p.id === oldPort.id && isCompatible(oldPort, p));
118
+ if (idMatch)
119
+ return idMatch;
120
+ // Pass 2: name match (case-insensitive)
121
+ const oldNameLower = oldPort.name.toLowerCase();
122
+ const nameMatch = available.find((p) => p.name.toLowerCase() === oldNameLower && isCompatible(oldPort, p));
123
+ if (nameMatch)
124
+ return nameMatch;
125
+ // Pass 3: first compatible dataType
126
+ const typeMatch = available.find((p) => isCompatible(oldPort, p));
127
+ return typeMatch ?? null;
128
+ }
129
+ /**
130
+ * Resolve the port metadata for an edge endpoint on a given node.
131
+ */
132
+ function resolvePort(node, handleId, direction) {
133
+ if (!handleId)
134
+ return null;
135
+ const portId = extractPortId(handleId);
136
+ if (!portId)
137
+ return null;
138
+ const ports = direction === "input"
139
+ ? node.data.metadata.inputs
140
+ : node.data.metadata.outputs;
141
+ return ports.find((p) => p.id === portId) ?? null;
142
+ }
143
+ // =========================================================================
144
+ // Swap preview computation
145
+ // =========================================================================
146
+ /**
147
+ * Compute a preview of what will happen when swapping oldNode with newMetadata.
148
+ *
149
+ * This does NOT mutate anything — it returns a preview that can be displayed
150
+ * to the user for confirmation before executing the swap.
151
+ */
152
+ export function computeSwapPreview(oldNode, newMetadata, edges, allNodes, checker = null) {
153
+ const oldNodeId = oldNode.id;
154
+ const newNodeId = generateNodeId(newMetadata.id, allNodes);
155
+ // Collect all edges connected to the old node
156
+ const connectedEdges = edges.filter((e) => e.source === oldNodeId || e.target === oldNodeId);
157
+ // Track which ports on the new node have been claimed
158
+ const usedInputPortIds = new Set();
159
+ const usedOutputPortIds = new Set();
160
+ const keptEdges = [];
161
+ const droppedEdges = [];
162
+ for (const edge of connectedEdges) {
163
+ const isSource = edge.source === oldNodeId;
164
+ const direction = isSource ? "output" : "input";
165
+ const handleId = isSource ? edge.sourceHandle : edge.targetHandle;
166
+ const usedPorts = isSource ? usedOutputPortIds : usedInputPortIds;
167
+ // Resolve the old port
168
+ const oldPort = resolvePort(oldNode, handleId, direction);
169
+ if (!oldPort) {
170
+ droppedEdges.push({
171
+ edge,
172
+ reason: `Port not found on original node`,
173
+ });
174
+ continue;
175
+ }
176
+ // Find matching port on new node
177
+ const newPorts = direction === "input" ? newMetadata.inputs : newMetadata.outputs;
178
+ const match = findMatchingPort(oldPort, newPorts, usedPorts, checker);
179
+ if (!match) {
180
+ droppedEdges.push({
181
+ edge,
182
+ reason: `No compatible ${direction} port found on "${newMetadata.name}"`,
183
+ });
184
+ continue;
185
+ }
186
+ usedPorts.add(match.id);
187
+ // Build the rewritten edge
188
+ const newHandleId = buildHandleId(newNodeId, direction, match.id);
189
+ const newEdge = { ...edge };
190
+ if (isSource) {
191
+ newEdge.source = newNodeId;
192
+ newEdge.sourceHandle = newHandleId;
193
+ }
194
+ else {
195
+ newEdge.target = newNodeId;
196
+ newEdge.targetHandle = newHandleId;
197
+ }
198
+ keptEdges.push({ edge, newEdge });
199
+ }
200
+ // Config mapping preview
201
+ const { carriedOver, reset } = mapConfig(oldNode.data.config, newMetadata.configSchema, newMetadata.config);
202
+ return {
203
+ keptEdges,
204
+ droppedEdges,
205
+ hasDataLoss: droppedEdges.length > 0,
206
+ newNodeId,
207
+ configCarriedOver: carriedOver,
208
+ configReset: reset,
209
+ };
210
+ }
211
+ // =========================================================================
212
+ // Swap execution
213
+ // =========================================================================
214
+ /**
215
+ * Execute a node swap using a previously computed preview.
216
+ *
217
+ * Returns new nodes and edges arrays ready for `workflowActions.batchUpdate()`.
218
+ */
219
+ export function executeSwap(oldNode, newMetadata, preview, allNodes, allEdges) {
220
+ const oldNodeId = oldNode.id;
221
+ const newNodeId = preview.newNodeId;
222
+ // Map config
223
+ const { config: mappedConfig } = mapConfig(oldNode.data.config, newMetadata.configSchema, newMetadata.config);
224
+ // Build the new node
225
+ const extensions = {
226
+ ...oldNode.data.extensions,
227
+ swap: {
228
+ previousNodeId: oldNodeId,
229
+ },
230
+ };
231
+ const newNode = {
232
+ id: newNodeId,
233
+ type: "universalNode",
234
+ position: { ...oldNode.position },
235
+ deletable: oldNode.deletable,
236
+ data: {
237
+ label: newMetadata.name,
238
+ config: mappedConfig,
239
+ metadata: newMetadata,
240
+ extensions,
241
+ },
242
+ };
243
+ // Build dropped edge IDs set for fast lookup
244
+ const droppedEdgeIds = new Set(preview.droppedEdges.map((d) => d.edge.id));
245
+ // Build a map from old edge ID → new edge for kept edges
246
+ const keptEdgeMap = new Map();
247
+ for (const { edge, newEdge } of preview.keptEdges) {
248
+ keptEdgeMap.set(edge.id, newEdge);
249
+ }
250
+ // Build updated edges: skip dropped, replace kept, pass through unrelated
251
+ const updatedEdges = [];
252
+ for (const edge of allEdges) {
253
+ if (droppedEdgeIds.has(edge.id))
254
+ continue;
255
+ const replacement = keptEdgeMap.get(edge.id);
256
+ if (replacement) {
257
+ updatedEdges.push(replacement);
258
+ }
259
+ else {
260
+ updatedEdges.push(edge);
261
+ }
262
+ }
263
+ // Build updated nodes: replace old node with new node (preserving array order)
264
+ const updatedNodes = allNodes.map((node) => node.id === oldNodeId ? newNode : node);
265
+ return { updatedNodes, updatedEdges };
266
+ }
267
+ // =========================================================================
268
+ // Version upgrade detection
269
+ // =========================================================================
270
+ /**
271
+ * Check if a newer version of the same node type is available.
272
+ *
273
+ * Compares the node's embedded metadata.version against the same-ID entry
274
+ * in the available nodes list (API returns only the latest version).
275
+ *
276
+ * @returns The newer NodeMetadata if an upgrade is available, null otherwise
277
+ */
278
+ export function getVersionUpgrade(currentMetadata, allNodeTypes) {
279
+ const available = allNodeTypes.find((n) => n.id === currentMetadata.id);
280
+ if (!available)
281
+ return null;
282
+ if (compareSemver(available.version, currentMetadata.version) > 0) {
283
+ return available;
284
+ }
285
+ return null;
286
+ }
287
+ // =========================================================================
288
+ // Phase 2 — Advanced Swap Functions
289
+ // =========================================================================
290
+ /**
291
+ * Determine the MatchQuality for how a port was matched.
292
+ */
293
+ function classifyMatch(oldPort, matchedPort) {
294
+ if (!matchedPort)
295
+ return "unmapped";
296
+ if (matchedPort.id === oldPort.id)
297
+ return "id";
298
+ if (matchedPort.name.toLowerCase() === oldPort.name.toLowerCase())
299
+ return "name";
300
+ return "type";
301
+ }
302
+ /**
303
+ * Compute a swap preview with full options support (strategies, overrides).
304
+ *
305
+ * Resolution order:
306
+ * 1. Check strategies — first canHandle() match wins for mapPorts()/mapConfig()
307
+ * 2. Fall through to built-in 3-pass for ports not covered by strategy
308
+ * 3. Apply portOverrides on top (highest priority — user's manual overrides)
309
+ * 4. Same cascade for config
310
+ */
311
+ export function computeSwapPreviewWithOptions(oldNode, newMetadata, edges, allNodes, options) {
312
+ const checker = options.checker ?? null;
313
+ const oldNodeId = oldNode.id;
314
+ const newNodeId = generateNodeId(newMetadata.id, allNodes);
315
+ // Collect connected edges
316
+ const connectedEdges = edges.filter((e) => e.source === oldNodeId || e.target === oldNodeId);
317
+ // Try strategy-based port mapping
318
+ let strategyPortMap;
319
+ let strategyConfigMap;
320
+ if (options.strategies?.length) {
321
+ const ctx = {
322
+ oldNode,
323
+ newMetadata,
324
+ edges,
325
+ allNodes,
326
+ checker,
327
+ };
328
+ for (const strategy of options.strategies) {
329
+ if (strategy.canHandle(ctx)) {
330
+ strategyPortMap = strategy.mapPorts?.(ctx);
331
+ strategyConfigMap = strategy.mapConfig?.(ctx);
332
+ break; // first match wins
333
+ }
334
+ }
335
+ }
336
+ // Build port override lookup (highest priority)
337
+ const portOverrideLookup = new Map();
338
+ for (const override of options.portOverrides ?? []) {
339
+ portOverrideLookup.set(`${override.direction}:${override.oldPortId}`, override.newPortId);
340
+ }
341
+ // Track used ports
342
+ const usedInputPortIds = new Set();
343
+ const usedOutputPortIds = new Set();
344
+ const keptEdges = [];
345
+ const droppedEdges = [];
346
+ for (const edge of connectedEdges) {
347
+ const isSource = edge.source === oldNodeId;
348
+ const direction = isSource ? "output" : "input";
349
+ const handleId = isSource ? edge.sourceHandle : edge.targetHandle;
350
+ const usedPorts = isSource ? usedOutputPortIds : usedInputPortIds;
351
+ const oldPort = resolvePort(oldNode, handleId, direction);
352
+ if (!oldPort) {
353
+ droppedEdges.push({ edge, reason: "Port not found on original node" });
354
+ continue;
355
+ }
356
+ // Priority 1: Manual port override
357
+ const overrideKey = `${direction}:${oldPort.id}`;
358
+ if (portOverrideLookup.has(overrideKey)) {
359
+ const overrideNewPortId = portOverrideLookup.get(overrideKey);
360
+ if (overrideNewPortId === null) {
361
+ droppedEdges.push({ edge, reason: "Manually dropped" });
362
+ continue;
363
+ }
364
+ const newPorts = direction === "input" ? newMetadata.inputs : newMetadata.outputs;
365
+ const overridePort = newPorts.find((p) => p.id === overrideNewPortId);
366
+ if (overridePort) {
367
+ usedPorts.add(overridePort.id);
368
+ const newHandleId = buildHandleId(newNodeId, direction, overridePort.id);
369
+ const newEdge = { ...edge };
370
+ if (isSource) {
371
+ newEdge.source = newNodeId;
372
+ newEdge.sourceHandle = newHandleId;
373
+ }
374
+ else {
375
+ newEdge.target = newNodeId;
376
+ newEdge.targetHandle = newHandleId;
377
+ }
378
+ keptEdges.push({ edge, newEdge });
379
+ continue;
380
+ }
381
+ }
382
+ // Priority 2: Strategy port mapping
383
+ if (strategyPortMap && oldPort.id in strategyPortMap) {
384
+ const strategyNewPortId = strategyPortMap[oldPort.id];
385
+ if (strategyNewPortId === null) {
386
+ droppedEdges.push({ edge, reason: "Dropped by strategy" });
387
+ continue;
388
+ }
389
+ const newPorts = direction === "input" ? newMetadata.inputs : newMetadata.outputs;
390
+ const strategyPort = newPorts.find((p) => p.id === strategyNewPortId);
391
+ if (strategyPort && !usedPorts.has(strategyPort.id)) {
392
+ usedPorts.add(strategyPort.id);
393
+ const newHandleId = buildHandleId(newNodeId, direction, strategyPort.id);
394
+ const newEdge = { ...edge };
395
+ if (isSource) {
396
+ newEdge.source = newNodeId;
397
+ newEdge.sourceHandle = newHandleId;
398
+ }
399
+ else {
400
+ newEdge.target = newNodeId;
401
+ newEdge.targetHandle = newHandleId;
402
+ }
403
+ keptEdges.push({ edge, newEdge });
404
+ continue;
405
+ }
406
+ }
407
+ // Priority 3: Built-in 3-pass matching
408
+ const newPorts = direction === "input" ? newMetadata.inputs : newMetadata.outputs;
409
+ const match = findMatchingPort(oldPort, newPorts, usedPorts, checker);
410
+ if (!match) {
411
+ droppedEdges.push({
412
+ edge,
413
+ reason: `No compatible ${direction} port found on "${newMetadata.name}"`,
414
+ });
415
+ continue;
416
+ }
417
+ usedPorts.add(match.id);
418
+ const newHandleId = buildHandleId(newNodeId, direction, match.id);
419
+ const newEdge = { ...edge };
420
+ if (isSource) {
421
+ newEdge.source = newNodeId;
422
+ newEdge.sourceHandle = newHandleId;
423
+ }
424
+ else {
425
+ newEdge.target = newNodeId;
426
+ newEdge.targetHandle = newHandleId;
427
+ }
428
+ keptEdges.push({ edge, newEdge });
429
+ }
430
+ // Config mapping — apply strategy then overrides
431
+ const { config: baseConfig, carriedOver, reset } = mapConfig(oldNode.data.config, newMetadata.configSchema, newMetadata.config);
432
+ // Apply strategy config overrides
433
+ if (strategyConfigMap) {
434
+ for (const [key, mapping] of Object.entries(strategyConfigMap)) {
435
+ if (mapping.action === "carry" && key in oldNode.data.config) {
436
+ if (!carriedOver.includes(key))
437
+ carriedOver.push(key);
438
+ const resetIdx = reset.indexOf(key);
439
+ if (resetIdx >= 0)
440
+ reset.splice(resetIdx, 1);
441
+ }
442
+ else if (mapping.action === "reset") {
443
+ if (!reset.includes(key))
444
+ reset.push(key);
445
+ const carryIdx = carriedOver.indexOf(key);
446
+ if (carryIdx >= 0)
447
+ carriedOver.splice(carryIdx, 1);
448
+ }
449
+ }
450
+ }
451
+ // Apply manual config overrides (highest priority)
452
+ for (const override of options.configOverrides ?? []) {
453
+ if (override.action === "carry" && override.key in oldNode.data.config) {
454
+ if (!carriedOver.includes(override.key))
455
+ carriedOver.push(override.key);
456
+ const resetIdx = reset.indexOf(override.key);
457
+ if (resetIdx >= 0)
458
+ reset.splice(resetIdx, 1);
459
+ }
460
+ else if (override.action === "reset") {
461
+ if (!reset.includes(override.key))
462
+ reset.push(override.key);
463
+ const carryIdx = carriedOver.indexOf(override.key);
464
+ if (carryIdx >= 0)
465
+ carriedOver.splice(carryIdx, 1);
466
+ }
467
+ else if (override.action === "set") {
468
+ if (!carriedOver.includes(override.key))
469
+ carriedOver.push(override.key);
470
+ const resetIdx = reset.indexOf(override.key);
471
+ if (resetIdx >= 0)
472
+ reset.splice(resetIdx, 1);
473
+ }
474
+ }
475
+ return {
476
+ keptEdges,
477
+ droppedEdges,
478
+ hasDataLoss: droppedEdges.length > 0,
479
+ newNodeId,
480
+ configCarriedOver: carriedOver,
481
+ configReset: reset,
482
+ };
483
+ }
484
+ /**
485
+ * Compute interactive state for the mapping editor UI.
486
+ *
487
+ * Returns EditablePortMapping and EditableConfigMapping entries
488
+ * with match quality annotations and isFlat flags.
489
+ */
490
+ export function computeInteractiveState(oldNode, newMetadata, edges, allNodes, options = {}) {
491
+ const checker = options.checker ?? null;
492
+ const oldNodeId = oldNode.id;
493
+ const newNodeId = generateNodeId(newMetadata.id, allNodes);
494
+ const connectedEdges = edges.filter((e) => e.source === oldNodeId || e.target === oldNodeId);
495
+ // Compute the base preview to get auto-matched ports
496
+ const preview = computeSwapPreviewWithOptions(oldNode, newMetadata, edges, allNodes, options);
497
+ // Build a map from edge id → new port id for kept edges
498
+ const keptEdgePortMap = new Map();
499
+ for (const { edge, newEdge } of preview.keptEdges) {
500
+ const isSource = edge.source === oldNodeId;
501
+ const handle = isSource ? newEdge.sourceHandle : newEdge.targetHandle;
502
+ const portId = extractPortId(handle ?? undefined);
503
+ if (portId)
504
+ keptEdgePortMap.set(edge.id, portId);
505
+ }
506
+ // Build port mappings
507
+ const portMappings = [];
508
+ for (const edge of connectedEdges) {
509
+ const isSource = edge.source === oldNodeId;
510
+ const direction = isSource ? "output" : "input";
511
+ const handleId = isSource ? edge.sourceHandle : edge.targetHandle;
512
+ const oldPort = resolvePort(oldNode, handleId, direction);
513
+ if (!oldPort)
514
+ continue;
515
+ const matchedPortId = keptEdgePortMap.get(edge.id) ?? null;
516
+ const newPorts = direction === "input" ? newMetadata.inputs : newMetadata.outputs;
517
+ const matchedPort = matchedPortId
518
+ ? newPorts.find((p) => p.id === matchedPortId) ?? null
519
+ : null;
520
+ const matchQuality = classifyMatch(oldPort, matchedPort);
521
+ portMappings.push({
522
+ oldPort,
523
+ edge,
524
+ direction,
525
+ selectedNewPortId: matchedPortId,
526
+ matchQuality,
527
+ autoSuggestedPortId: matchedPortId,
528
+ isOverridden: false,
529
+ });
530
+ }
531
+ // Build config mappings
532
+ const configMappings = [];
533
+ const newSchema = newMetadata.configSchema;
534
+ if (newSchema?.properties) {
535
+ for (const [key, prop] of Object.entries(newSchema.properties)) {
536
+ if (DYNAMIC_PORT_KEYS.has(key))
537
+ continue;
538
+ const oldValue = oldNode.data.config[key];
539
+ const schemaDefault = prop?.default;
540
+ const providedDefault = newMetadata.config?.[key];
541
+ const newDefault = providedDefault !== undefined ? providedDefault : schemaDefault;
542
+ const hasOldValue = key in oldNode.data.config;
543
+ const isFlat = !hasOldValue || isPrimitive(oldValue);
544
+ configMappings.push({
545
+ key,
546
+ title: prop?.title ?? key,
547
+ oldValue,
548
+ newDefault,
549
+ carryOver: hasOldValue && isFlat,
550
+ autoCarryOver: hasOldValue && isFlat,
551
+ isFlat,
552
+ });
553
+ }
554
+ }
555
+ return {
556
+ oldNode,
557
+ newMetadata,
558
+ newNodeId,
559
+ portMappings,
560
+ configMappings,
561
+ availableNewInputs: [...newMetadata.inputs],
562
+ availableNewOutputs: [...newMetadata.outputs],
563
+ };
564
+ }
565
+ /** Check if a value is a primitive (string, number, boolean, null, undefined). */
566
+ function isPrimitive(value) {
567
+ if (value === null || value === undefined)
568
+ return true;
569
+ const t = typeof value;
570
+ return t === "string" || t === "number" || t === "boolean";
571
+ }
572
+ /**
573
+ * Convert user-edited InteractiveSwapState back into a SwapPreview
574
+ * for executeSwap(). Pure function, no side effects.
575
+ */
576
+ export function buildSwapPreviewFromState(state, allEdges) {
577
+ const keptEdges = [];
578
+ const droppedEdges = [];
579
+ for (const mapping of state.portMappings) {
580
+ if (!mapping.selectedNewPortId) {
581
+ droppedEdges.push({
582
+ edge: mapping.edge,
583
+ reason: mapping.matchQuality === "unmapped"
584
+ ? `No compatible ${mapping.direction} port found on "${state.newMetadata.name}"`
585
+ : "Manually dropped",
586
+ });
587
+ continue;
588
+ }
589
+ const isSource = mapping.edge.source === state.oldNode.id;
590
+ const newHandleId = buildHandleId(state.newNodeId, mapping.direction, mapping.selectedNewPortId);
591
+ const newEdge = { ...mapping.edge };
592
+ if (isSource) {
593
+ newEdge.source = state.newNodeId;
594
+ newEdge.sourceHandle = newHandleId;
595
+ }
596
+ else {
597
+ newEdge.target = state.newNodeId;
598
+ newEdge.targetHandle = newHandleId;
599
+ }
600
+ keptEdges.push({ edge: mapping.edge, newEdge });
601
+ }
602
+ // Build config lists from interactive state
603
+ const configCarriedOver = [];
604
+ const configReset = [];
605
+ for (const cm of state.configMappings) {
606
+ if (cm.carryOver && cm.isFlat) {
607
+ configCarriedOver.push(cm.key);
608
+ }
609
+ else {
610
+ configReset.push(cm.key);
611
+ }
612
+ }
613
+ return {
614
+ keptEdges,
615
+ droppedEdges,
616
+ hasDataLoss: droppedEdges.length > 0,
617
+ newNodeId: state.newNodeId,
618
+ configCarriedOver,
619
+ configReset,
620
+ };
621
+ }
622
+ /**
623
+ * Headless one-shot swap with full validation.
624
+ *
625
+ * Guardrails:
626
+ * - Validates oldNode.id exists in allNodes
627
+ * - Validates format compatibility if newMetadata.formats is set
628
+ * - Computes preview → executes → validates → returns result
629
+ * - Throws SwapValidationError on invalid input
630
+ */
631
+ export function performSwap(oldNode, newMetadata, allNodes, allEdges, options) {
632
+ // Validate oldNode exists
633
+ const exists = allNodes.some((n) => n.id === oldNode.id);
634
+ if (!exists) {
635
+ throw new SwapValidationError(`Node "${oldNode.id}" not found in the workflow`);
636
+ }
637
+ // Compute preview
638
+ const preview = options
639
+ ? computeSwapPreviewWithOptions(oldNode, newMetadata, allEdges, allNodes, options)
640
+ : computeSwapPreview(oldNode, newMetadata, allEdges, allNodes, null);
641
+ // Execute
642
+ const result = executeSwap(oldNode, newMetadata, preview, allNodes, allEdges);
643
+ // Post-swap validation
644
+ const validation = validateSwapResult(result);
645
+ if (!validation.valid) {
646
+ throw new SwapValidationError(`Post-swap validation failed: ${validation.error}`);
647
+ }
648
+ return result;
649
+ }
650
+ /**
651
+ * Validate a swap result for structural integrity.
652
+ *
653
+ * Checks:
654
+ * - No dangling edge references (every edge source/target exists in nodes)
655
+ * - No duplicate node IDs
656
+ * - No duplicate edge IDs
657
+ */
658
+ export function validateSwapResult(result) {
659
+ const nodeIds = new Set();
660
+ for (const node of result.updatedNodes) {
661
+ if (nodeIds.has(node.id)) {
662
+ return { valid: false, error: `Duplicate node ID: "${node.id}"` };
663
+ }
664
+ nodeIds.add(node.id);
665
+ }
666
+ const edgeIds = new Set();
667
+ for (const edge of result.updatedEdges) {
668
+ if (edgeIds.has(edge.id)) {
669
+ return { valid: false, error: `Duplicate edge ID: "${edge.id}"` };
670
+ }
671
+ edgeIds.add(edge.id);
672
+ if (!nodeIds.has(edge.source)) {
673
+ return {
674
+ valid: false,
675
+ error: `Dangling edge "${edge.id}": source node "${edge.source}" not found`,
676
+ };
677
+ }
678
+ if (!nodeIds.has(edge.target)) {
679
+ return {
680
+ valid: false,
681
+ error: `Dangling edge "${edge.id}": target node "${edge.target}" not found`,
682
+ };
683
+ }
684
+ }
685
+ return { valid: true };
686
+ }