@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.
- package/README.md +68 -24
- package/dist/adapters/WorkflowAdapter.js +2 -22
- package/dist/adapters/agentspec/autoLayout.d.ts +51 -5
- package/dist/adapters/agentspec/autoLayout.js +120 -23
- package/dist/chat/commandClassifier.d.ts +19 -0
- package/dist/chat/commandClassifier.js +30 -0
- package/dist/chat/index.d.ts +27 -0
- package/dist/chat/index.js +32 -0
- package/dist/chat/responseParser.d.ts +21 -0
- package/dist/chat/responseParser.js +87 -0
- package/dist/commands/batch.d.ts +18 -0
- package/dist/commands/batch.js +56 -0
- package/dist/commands/executor.d.ts +37 -0
- package/dist/commands/executor.js +1044 -0
- package/dist/commands/index.d.ts +14 -0
- package/dist/commands/index.js +17 -0
- package/dist/commands/parser.d.ts +16 -0
- package/dist/commands/parser.js +278 -0
- package/dist/commands/positioner.d.ts +19 -0
- package/dist/commands/positioner.js +33 -0
- package/dist/commands/storeIntegration.svelte.d.ts +16 -0
- package/dist/commands/storeIntegration.svelte.js +67 -0
- package/dist/commands/types.d.ts +343 -0
- package/dist/commands/types.js +45 -0
- package/dist/components/App.svelte +431 -17
- package/dist/components/App.svelte.d.ts +10 -0
- package/dist/components/CanvasBanner.stories.svelte +6 -2
- package/dist/components/CanvasController.svelte +38 -0
- package/dist/components/CanvasController.svelte.d.ts +32 -0
- package/dist/components/ConfigMappingRow.svelte +130 -0
- package/dist/components/ConfigMappingRow.svelte.d.ts +8 -0
- package/dist/components/ConfigPanel.svelte +56 -7
- package/dist/components/ConfigPanel.svelte.d.ts +2 -0
- package/dist/components/FlowDropEdge.svelte +8 -57
- package/dist/components/Logo.svelte +14 -14
- package/dist/components/LogsSidebar.svelte +5 -5
- package/dist/components/Navbar.svelte +58 -10
- package/dist/components/Navbar.svelte.d.ts +7 -0
- package/dist/components/NodeSidebar.svelte +238 -362
- package/dist/components/NodeSwapPicker.svelte +537 -0
- package/dist/components/NodeSwapPicker.svelte.d.ts +16 -0
- package/dist/components/PortMappingRow.svelte +209 -0
- package/dist/components/PortMappingRow.svelte.d.ts +12 -0
- package/dist/components/SwapMappingEditor.svelte +550 -0
- package/dist/components/SwapMappingEditor.svelte.d.ts +12 -0
- package/dist/components/WorkflowEditor.svelte +99 -4
- package/dist/components/WorkflowEditor.svelte.d.ts +8 -0
- package/dist/components/chat/AIChatPanel.svelte +658 -0
- package/dist/components/chat/AIChatPanel.svelte.d.ts +13 -0
- package/dist/components/chat/CommandPreview.svelte +184 -0
- package/dist/components/chat/CommandPreview.svelte.d.ts +9 -0
- package/dist/components/console/CommandConsole.stories.svelte +93 -0
- package/dist/components/console/CommandConsole.stories.svelte.d.ts +27 -0
- package/dist/components/console/CommandConsole.svelte +259 -0
- package/dist/components/console/CommandConsole.svelte.d.ts +11 -0
- package/dist/components/console/ConsoleAutocomplete.svelte +139 -0
- package/dist/components/console/ConsoleAutocomplete.svelte.d.ts +21 -0
- package/dist/components/console/ConsoleInput.svelte +712 -0
- package/dist/components/console/ConsoleInput.svelte.d.ts +16 -0
- package/dist/components/console/ConsoleOutput.svelte +121 -0
- package/dist/components/console/ConsoleOutput.svelte.d.ts +11 -0
- package/dist/components/console/formatters.d.ts +26 -0
- package/dist/components/console/formatters.js +118 -0
- package/dist/components/interrupt/index.d.ts +1 -0
- package/dist/components/interrupt/index.js +1 -0
- package/dist/components/nodes/SimpleNode.stories.svelte +64 -0
- package/dist/components/nodes/SimpleNode.svelte +27 -11
- package/dist/components/nodes/SquareNode.stories.svelte +45 -0
- package/dist/components/nodes/SquareNode.svelte +27 -11
- package/dist/components/nodes/WorkflowNode.stories.svelte +63 -0
- package/dist/config/endpoints.d.ts +8 -0
- package/dist/config/endpoints.js +5 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +9 -0
- package/dist/editor/index.d.ts +3 -1
- package/dist/editor/index.js +4 -2
- package/dist/helpers/proximityConnect.js +8 -1
- package/dist/helpers/workflowEditorHelper.d.ts +3 -53
- package/dist/helpers/workflowEditorHelper.js +13 -228
- package/dist/playground/index.d.ts +1 -1
- package/dist/playground/index.js +1 -1
- package/dist/schemas/v1/workflow.schema.json +107 -22
- package/dist/services/chatService.d.ts +65 -0
- package/dist/services/chatService.js +131 -0
- package/dist/services/historyService.d.ts +6 -4
- package/dist/services/historyService.js +21 -6
- package/dist/skins/slate.js +16 -0
- package/dist/stores/interruptStore.svelte.js +6 -1
- package/dist/stores/playgroundStore.svelte.d.ts +1 -1
- package/dist/stores/playgroundStore.svelte.js +11 -2
- package/dist/stores/portCoordinateStore.svelte.d.ts +4 -0
- package/dist/stores/portCoordinateStore.svelte.js +20 -26
- package/dist/stores/workflowStore.svelte.d.ts +31 -2
- package/dist/stores/workflowStore.svelte.js +84 -64
- package/dist/stories/EdgeDecorator.svelte +4 -4
- package/dist/styles/base.css +48 -0
- package/dist/svelte-app.d.ts +7 -1
- package/dist/svelte-app.js +4 -1
- package/dist/types/chat.d.ts +63 -0
- package/dist/types/chat.js +9 -0
- package/dist/types/events.d.ts +28 -2
- package/dist/types/events.js +1 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/settings.d.ts +6 -0
- package/dist/types/settings.js +3 -0
- package/dist/utils/edgeStyling.d.ts +42 -0
- package/dist/utils/edgeStyling.js +176 -0
- package/dist/utils/nodeIds.d.ts +31 -0
- package/dist/utils/nodeIds.js +42 -0
- package/dist/utils/nodeSwap.d.ts +221 -0
- package/dist/utils/nodeSwap.js +686 -0
- package/package.json +6 -1
- package/dist/helpers/nodeLayoutHelper.d.ts +0 -14
- 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
|
+
}
|