@ema.co/mcp-toolkit 1.4.3 → 1.5.1
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.
Potentially problematic release.
This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.
- package/dist/mcp/handlers-consolidated.js +647 -85
- package/dist/mcp/tools-consolidated.js +73 -42
- package/dist/sdk/index.js +4 -0
- package/dist/sdk/knowledge.js +934 -0
- package/dist/sdk/workflow-execution-analyzer.js +412 -0
- package/dist/sdk/workflow-fixer.js +272 -0
- package/dist/sdk/workflow-generator.js +1 -0
- package/dist/sdk/workflow-transformer.js +602 -0
- package/docs/llm-native-workflow-design.md +252 -0
- package/package.json +1 -1
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Execution Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Deep analysis of workflow execution logic learned from Auto Builder insights:
|
|
5
|
+
* - Loop detection (circular, infinite, re-entry)
|
|
6
|
+
* - Multiple responder detection (causes duplicate/triple responses)
|
|
7
|
+
* - Ungated catch-all detection
|
|
8
|
+
* - Redundant classifier detection
|
|
9
|
+
* - Data flow analysis
|
|
10
|
+
* - runIf condition validation
|
|
11
|
+
* - Parallel execution opportunities
|
|
12
|
+
* - Dead code path detection
|
|
13
|
+
*/
|
|
14
|
+
import { parseWorkflowDef } from "./knowledge.js";
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
// GRAPH BUILDING
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
function buildGraphMaps(nodes) {
|
|
19
|
+
const forward = new Map();
|
|
20
|
+
const reverse = new Map();
|
|
21
|
+
const nodeMap = new Map();
|
|
22
|
+
for (const node of nodes) {
|
|
23
|
+
nodeMap.set(node.id, node);
|
|
24
|
+
forward.set(node.id, new Set());
|
|
25
|
+
reverse.set(node.id, new Set());
|
|
26
|
+
}
|
|
27
|
+
for (const node of nodes) {
|
|
28
|
+
if (node.incoming_edges) {
|
|
29
|
+
for (const edge of node.incoming_edges) {
|
|
30
|
+
const sourceId = edge.source_node_id;
|
|
31
|
+
forward.get(sourceId)?.add(node.id);
|
|
32
|
+
reverse.get(node.id)?.add(sourceId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { forward, reverse, nodeMap };
|
|
37
|
+
}
|
|
38
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
39
|
+
// LOOP DETECTION
|
|
40
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
+
export function detectLoops(workflowDef) {
|
|
42
|
+
const nodes = parseWorkflowDef(workflowDef);
|
|
43
|
+
const loops = [];
|
|
44
|
+
const { forward, nodeMap } = buildGraphMaps(nodes);
|
|
45
|
+
// DFS for cycle detection
|
|
46
|
+
const visited = new Set();
|
|
47
|
+
const recursionStack = new Set();
|
|
48
|
+
const path = [];
|
|
49
|
+
function dfs(nodeId) {
|
|
50
|
+
visited.add(nodeId);
|
|
51
|
+
recursionStack.add(nodeId);
|
|
52
|
+
path.push(nodeId);
|
|
53
|
+
for (const neighbor of (forward.get(nodeId) || [])) {
|
|
54
|
+
if (!visited.has(neighbor)) {
|
|
55
|
+
if (dfs(neighbor))
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
else if (recursionStack.has(neighbor)) {
|
|
59
|
+
const cycleStart = path.indexOf(neighbor);
|
|
60
|
+
const cyclePath = path.slice(cycleStart);
|
|
61
|
+
cyclePath.push(neighbor);
|
|
62
|
+
loops.push({
|
|
63
|
+
type: 'circular_dependency',
|
|
64
|
+
nodes: cyclePath,
|
|
65
|
+
description: `Circular dependency: ${cyclePath.join(' → ')}`,
|
|
66
|
+
severity: 'critical',
|
|
67
|
+
fixSuggestion: `Break the cycle by removing one edge.`,
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
path.pop();
|
|
73
|
+
recursionStack.delete(nodeId);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const trigger = nodes.find(n => n.action_name === 'trigger' || n.id === 'trigger');
|
|
77
|
+
if (trigger)
|
|
78
|
+
dfs(trigger.id);
|
|
79
|
+
for (const node of nodes) {
|
|
80
|
+
if (!visited.has(node.id))
|
|
81
|
+
dfs(node.id);
|
|
82
|
+
}
|
|
83
|
+
// Detect categorizer routing back to upstream (re-entry)
|
|
84
|
+
for (const node of nodes) {
|
|
85
|
+
if (node.action_name === 'chat_categorizer') {
|
|
86
|
+
const downstream = forward.get(node.id) || new Set();
|
|
87
|
+
const upstream = new Set();
|
|
88
|
+
function findUpstream(nId, depth = 0) {
|
|
89
|
+
if (depth > 20)
|
|
90
|
+
return;
|
|
91
|
+
const nodeInfo = nodeMap.get(nId);
|
|
92
|
+
if (!nodeInfo?.incoming_edges)
|
|
93
|
+
return;
|
|
94
|
+
for (const edge of nodeInfo.incoming_edges) {
|
|
95
|
+
upstream.add(edge.source_node_id);
|
|
96
|
+
findUpstream(edge.source_node_id, depth + 1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
findUpstream(node.id);
|
|
100
|
+
for (const downNode of downstream) {
|
|
101
|
+
if (upstream.has(downNode)) {
|
|
102
|
+
loops.push({
|
|
103
|
+
type: 'reentry_loop',
|
|
104
|
+
nodes: [node.id, downNode],
|
|
105
|
+
description: `Categorizer "${node.display_name || node.id}" routes to "${downNode}" which is upstream - can cause re-processing`,
|
|
106
|
+
severity: 'warning',
|
|
107
|
+
fixSuggestion: `Add state tracking or one-time-execution gate to prevent re-processing.`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return loops;
|
|
114
|
+
}
|
|
115
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
116
|
+
// MULTIPLE RESPONDER DETECTION (Key cause of "says same thing 3 times")
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
118
|
+
export function detectMultipleResponders(workflowDef) {
|
|
119
|
+
const nodes = parseWorkflowDef(workflowDef);
|
|
120
|
+
const issues = [];
|
|
121
|
+
const { reverse, nodeMap } = buildGraphMaps(nodes);
|
|
122
|
+
// Find all nodes that feed into WORKFLOW_OUTPUT
|
|
123
|
+
const def = workflowDef;
|
|
124
|
+
const resultMappings = def?.resultMappings;
|
|
125
|
+
const outputFeeders = [];
|
|
126
|
+
if (resultMappings) {
|
|
127
|
+
for (const mapping of resultMappings) {
|
|
128
|
+
const nodeName = mapping.namedOutput?.nodeName;
|
|
129
|
+
if (nodeName)
|
|
130
|
+
outputFeeders.push(nodeName);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Find responder-type nodes
|
|
134
|
+
const responderNodes = nodes.filter(n => n.action_name === 'call_llm' ||
|
|
135
|
+
n.action_name === 'custom_agent' ||
|
|
136
|
+
n.action_name === 'respond_with_sources' ||
|
|
137
|
+
n.action_name === 'fixed_response');
|
|
138
|
+
const respondersFeedingOutput = responderNodes.filter(n => outputFeeders.includes(n.id));
|
|
139
|
+
if (respondersFeedingOutput.length > 1) {
|
|
140
|
+
const ungatedResponders = respondersFeedingOutput.filter(n => !n.runIf);
|
|
141
|
+
const gatedResponders = respondersFeedingOutput.filter(n => n.runIf);
|
|
142
|
+
if (ungatedResponders.length > 0 && gatedResponders.length > 0) {
|
|
143
|
+
issues.push({
|
|
144
|
+
type: 'competing_outputs',
|
|
145
|
+
nodes: respondersFeedingOutput.map(n => n.id),
|
|
146
|
+
description: `${ungatedResponders.length} ungated + ${gatedResponders.length} gated responders compete for output`,
|
|
147
|
+
severity: 'critical',
|
|
148
|
+
fixSuggestion: `Gate ALL responders with mutually exclusive conditions. Ungated: ${ungatedResponders.map(n => n.display_name || n.id).join(', ')}`,
|
|
149
|
+
willCauseTripleResponse: ungatedResponders.length + gatedResponders.length >= 3,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (ungatedResponders.length > 1) {
|
|
153
|
+
issues.push({
|
|
154
|
+
type: 'parallel_responders',
|
|
155
|
+
nodes: ungatedResponders.map(n => n.id),
|
|
156
|
+
description: `${ungatedResponders.length} ungated responders fire simultaneously: ${ungatedResponders.map(n => n.display_name || n.id).join(', ')}`,
|
|
157
|
+
severity: 'critical',
|
|
158
|
+
fixSuggestion: `Add trigger_when conditions to make mutually exclusive, or consolidate into single responder`,
|
|
159
|
+
willCauseTripleResponse: ungatedResponders.length >= 3,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Find ALL ungated catch-all nodes (not just output feeders)
|
|
164
|
+
for (const node of nodes) {
|
|
165
|
+
if (node.action_name === 'trigger')
|
|
166
|
+
continue;
|
|
167
|
+
const isResponder = ['call_llm', 'custom_agent', 'respond_with_sources', 'fixed_response'].includes(node.action_name || '');
|
|
168
|
+
if (!isResponder)
|
|
169
|
+
continue;
|
|
170
|
+
if (node.runIf)
|
|
171
|
+
continue; // Has gating
|
|
172
|
+
const upstream = reverse.get(node.id) || new Set();
|
|
173
|
+
if (upstream.size > 0) {
|
|
174
|
+
// Check if ALL upstream paths are ungated
|
|
175
|
+
let allUpstreamUngated = true;
|
|
176
|
+
for (const upId of upstream) {
|
|
177
|
+
const upNode = nodeMap.get(upId);
|
|
178
|
+
if (upNode?.runIf || upNode?.action_name === 'chat_categorizer') {
|
|
179
|
+
allUpstreamUngated = false;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (allUpstreamUngated && !outputFeeders.includes(node.id)) {
|
|
184
|
+
issues.push({
|
|
185
|
+
type: 'ungated_responder',
|
|
186
|
+
nodes: [node.id],
|
|
187
|
+
description: `"${node.display_name || node.id}" always runs (no trigger_when gate)`,
|
|
188
|
+
severity: 'warning',
|
|
189
|
+
fixSuggestion: `Add runIf: { enum: { enumType: "category", enumValue: "specific_intent" } }`,
|
|
190
|
+
willCauseTripleResponse: false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return issues;
|
|
196
|
+
}
|
|
197
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
198
|
+
// REDUNDANT CLASSIFIER DETECTION
|
|
199
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
200
|
+
export function detectRedundantClassifiers(workflowDef) {
|
|
201
|
+
const nodes = parseWorkflowDef(workflowDef);
|
|
202
|
+
const issues = [];
|
|
203
|
+
const classifiers = nodes.filter(n => n.action_name === 'chat_categorizer' ||
|
|
204
|
+
n.action_name === 'intent_classifier');
|
|
205
|
+
if (classifiers.length > 1) {
|
|
206
|
+
// Group by input source
|
|
207
|
+
const classifiersByInput = new Map();
|
|
208
|
+
for (const classifier of classifiers) {
|
|
209
|
+
const inputSources = [];
|
|
210
|
+
if (classifier.incoming_edges) {
|
|
211
|
+
for (const edge of classifier.incoming_edges) {
|
|
212
|
+
if (edge.target_input === 'conversation' || edge.target_input === 'text_input') {
|
|
213
|
+
inputSources.push(edge.source_node_id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const key = inputSources.sort().join(',');
|
|
218
|
+
const existing = classifiersByInput.get(key) || [];
|
|
219
|
+
existing.push(classifier);
|
|
220
|
+
classifiersByInput.set(key, existing);
|
|
221
|
+
}
|
|
222
|
+
for (const [, group] of classifiersByInput) {
|
|
223
|
+
if (group.length > 1) {
|
|
224
|
+
issues.push({
|
|
225
|
+
classifiers: group.map(c => c.id),
|
|
226
|
+
description: `${group.map(c => c.display_name || c.id).join(' & ')} analyze same input - causes overlapping/conflicting routing`,
|
|
227
|
+
fixSuggestion: `Consolidate into single classifier, or chain sequentially with clear precedence`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return issues;
|
|
233
|
+
}
|
|
234
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
235
|
+
// DATA FLOW ISSUES
|
|
236
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
237
|
+
export function analyzeDataFlow(workflowDef) {
|
|
238
|
+
const nodes = parseWorkflowDef(workflowDef);
|
|
239
|
+
const issues = [];
|
|
240
|
+
const { forward, nodeMap } = buildGraphMaps(nodes);
|
|
241
|
+
for (const node of nodes) {
|
|
242
|
+
if (!node.incoming_edges)
|
|
243
|
+
continue;
|
|
244
|
+
for (const edge of node.incoming_edges) {
|
|
245
|
+
const sourceNode = nodeMap.get(edge.source_node_id);
|
|
246
|
+
if (!sourceNode) {
|
|
247
|
+
issues.push({
|
|
248
|
+
type: 'missing_data',
|
|
249
|
+
node: node.id,
|
|
250
|
+
description: `"${node.display_name || node.id}" expects data from non-existent "${edge.source_node_id}"`,
|
|
251
|
+
severity: 'critical',
|
|
252
|
+
fixSuggestion: `Add node "${edge.source_node_id}" or update input binding`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
// Check if source is gated but target isn't
|
|
256
|
+
if (sourceNode?.runIf && !node.runIf) {
|
|
257
|
+
issues.push({
|
|
258
|
+
type: 'gated_dependency',
|
|
259
|
+
node: node.id,
|
|
260
|
+
description: `"${node.display_name || node.id}" depends on gated "${sourceNode.display_name || sourceNode.id}" but has no gate itself - may not get data`,
|
|
261
|
+
severity: 'warning',
|
|
262
|
+
fixSuggestion: `Add matching runIf condition to "${node.display_name || node.id}"`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Orphaned outputs
|
|
268
|
+
for (const [nodeId, node] of nodeMap) {
|
|
269
|
+
const downstream = forward.get(nodeId) || new Set();
|
|
270
|
+
if (downstream.size === 0 &&
|
|
271
|
+
nodeId !== 'WORKFLOW_OUTPUT' &&
|
|
272
|
+
!['send_email_agent', 'send_communications_handler'].includes(node.action_name || '')) {
|
|
273
|
+
const def = workflowDef;
|
|
274
|
+
const resultMappings = def?.resultMappings;
|
|
275
|
+
const isMapped = resultMappings?.some((m) => {
|
|
276
|
+
const mapping = m;
|
|
277
|
+
return mapping.namedOutput?.nodeName === nodeId;
|
|
278
|
+
});
|
|
279
|
+
if (!isMapped) {
|
|
280
|
+
issues.push({
|
|
281
|
+
type: 'orphaned_output',
|
|
282
|
+
node: nodeId,
|
|
283
|
+
description: `"${node.display_name || nodeId}" output is never consumed`,
|
|
284
|
+
severity: 'warning',
|
|
285
|
+
fixSuggestion: `Connect to downstream node or WORKFLOW_OUTPUT, or remove if unused`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return issues;
|
|
291
|
+
}
|
|
292
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
293
|
+
// DEAD CODE DETECTION
|
|
294
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
295
|
+
export function findDeadCodePaths(workflowDef) {
|
|
296
|
+
const nodes = parseWorkflowDef(workflowDef);
|
|
297
|
+
const { forward } = buildGraphMaps(nodes);
|
|
298
|
+
const trigger = nodes.find(n => n.action_name === 'trigger' || n.id === 'trigger');
|
|
299
|
+
if (!trigger)
|
|
300
|
+
return nodes.map(n => n.id);
|
|
301
|
+
const reachable = new Set();
|
|
302
|
+
const queue = [trigger.id];
|
|
303
|
+
while (queue.length > 0) {
|
|
304
|
+
const current = queue.shift();
|
|
305
|
+
if (reachable.has(current))
|
|
306
|
+
continue;
|
|
307
|
+
reachable.add(current);
|
|
308
|
+
for (const next of (forward.get(current) || [])) {
|
|
309
|
+
queue.push(next);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return nodes.filter(n => !reachable.has(n.id) && n.id !== 'WORKFLOW_OUTPUT').map(n => n.id);
|
|
313
|
+
}
|
|
314
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
315
|
+
// MAIN ANALYSIS FUNCTION
|
|
316
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
317
|
+
export function analyzeExecutionFlow(workflowDef) {
|
|
318
|
+
const nodes = parseWorkflowDef(workflowDef);
|
|
319
|
+
const loops = detectLoops(workflowDef);
|
|
320
|
+
const multipleResponderIssues = detectMultipleResponders(workflowDef);
|
|
321
|
+
const redundantClassifiers = detectRedundantClassifiers(workflowDef);
|
|
322
|
+
const dataFlowIssues = analyzeDataFlow(workflowDef);
|
|
323
|
+
const deadCodePaths = findDeadCodePaths(workflowDef);
|
|
324
|
+
const criticalIssues = loops.filter(l => l.severity === 'critical').length +
|
|
325
|
+
multipleResponderIssues.filter(m => m.severity === 'critical').length +
|
|
326
|
+
dataFlowIssues.filter(d => d.severity === 'critical').length;
|
|
327
|
+
const summary = {
|
|
328
|
+
totalNodes: nodes.length,
|
|
329
|
+
reachableNodes: nodes.length - deadCodePaths.length,
|
|
330
|
+
unreachableNodes: deadCodePaths.length,
|
|
331
|
+
loopCount: loops.length,
|
|
332
|
+
criticalIssues,
|
|
333
|
+
warnings: loops.filter(l => l.severity === 'warning').length +
|
|
334
|
+
multipleResponderIssues.filter(m => m.severity === 'warning').length +
|
|
335
|
+
dataFlowIssues.filter(d => d.severity === 'warning').length,
|
|
336
|
+
mayRepeatResponses: multipleResponderIssues.some(m => m.willCauseTripleResponse),
|
|
337
|
+
ungatedResponderCount: multipleResponderIssues.filter(m => m.type === 'ungated_responder').length,
|
|
338
|
+
redundantClassifierCount: redundantClassifiers.length,
|
|
339
|
+
};
|
|
340
|
+
return {
|
|
341
|
+
loops,
|
|
342
|
+
multipleResponderIssues,
|
|
343
|
+
redundantClassifiers,
|
|
344
|
+
dataFlowIssues,
|
|
345
|
+
deadCodePaths,
|
|
346
|
+
summary,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
350
|
+
// ASCII VISUALIZATION
|
|
351
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
352
|
+
export function generateASCIIFlow(analysis) {
|
|
353
|
+
let output = '';
|
|
354
|
+
output += '╔══════════════════════════════════════════════════════════════════╗\n';
|
|
355
|
+
output += '║ EXECUTION FLOW ANALYSIS (Auto Builder Insights) ║\n';
|
|
356
|
+
output += '╚══════════════════════════════════════════════════════════════════╝\n\n';
|
|
357
|
+
output += `📊 SUMMARY\n`;
|
|
358
|
+
output += ` Total Nodes: ${analysis.summary.totalNodes}\n`;
|
|
359
|
+
output += ` Reachable: ${analysis.summary.reachableNodes}\n`;
|
|
360
|
+
output += ` Dead Code: ${analysis.summary.unreachableNodes}\n`;
|
|
361
|
+
output += ` Critical Issues: ${analysis.summary.criticalIssues}\n`;
|
|
362
|
+
output += ` Warnings: ${analysis.summary.warnings}\n`;
|
|
363
|
+
output += ` May Repeat Responses: ${analysis.summary.mayRepeatResponses ? '⚠️ YES - TRIPLE RESPONSE RISK!' : '✓ No'}\n`;
|
|
364
|
+
output += ` Ungated Responders: ${analysis.summary.ungatedResponderCount}\n`;
|
|
365
|
+
output += ` Redundant Classifiers: ${analysis.summary.redundantClassifierCount}\n\n`;
|
|
366
|
+
if (analysis.multipleResponderIssues.length > 0) {
|
|
367
|
+
output += `🔊 DUPLICATE RESPONSE RISKS (${analysis.multipleResponderIssues.length})\n`;
|
|
368
|
+
for (const issue of analysis.multipleResponderIssues) {
|
|
369
|
+
const icon = issue.severity === 'critical' ? '❌' : '⚠️';
|
|
370
|
+
output += ` ${icon} ${issue.type}\n`;
|
|
371
|
+
output += ` ${issue.description}\n`;
|
|
372
|
+
if (issue.willCauseTripleResponse)
|
|
373
|
+
output += ` ⚡ WILL CAUSE TRIPLE RESPONSE!\n`;
|
|
374
|
+
output += ` Fix: ${issue.fixSuggestion}\n\n`;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (analysis.redundantClassifiers.length > 0) {
|
|
378
|
+
output += `🔀 REDUNDANT CLASSIFIERS (${analysis.redundantClassifiers.length})\n`;
|
|
379
|
+
for (const issue of analysis.redundantClassifiers) {
|
|
380
|
+
output += ` ⚠️ ${issue.classifiers.join(', ')}\n`;
|
|
381
|
+
output += ` ${issue.description}\n`;
|
|
382
|
+
output += ` Fix: ${issue.fixSuggestion}\n\n`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (analysis.loops.length > 0) {
|
|
386
|
+
output += `🔄 LOOPS (${analysis.loops.length})\n`;
|
|
387
|
+
for (const loop of analysis.loops) {
|
|
388
|
+
const icon = loop.severity === 'critical' ? '❌' : '⚠️';
|
|
389
|
+
output += ` ${icon} ${loop.type}: ${loop.description}\n`;
|
|
390
|
+
output += ` Fix: ${loop.fixSuggestion}\n\n`;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (analysis.deadCodePaths.length > 0) {
|
|
394
|
+
output += `💀 DEAD CODE (${analysis.deadCodePaths.length})\n`;
|
|
395
|
+
output += ` Unreachable: ${analysis.deadCodePaths.slice(0, 5).join(', ')}`;
|
|
396
|
+
if (analysis.deadCodePaths.length > 5)
|
|
397
|
+
output += ` +${analysis.deadCodePaths.length - 5} more`;
|
|
398
|
+
output += '\n\n';
|
|
399
|
+
}
|
|
400
|
+
if (analysis.dataFlowIssues.length > 0) {
|
|
401
|
+
output += `📦 DATA FLOW (${analysis.dataFlowIssues.length})\n`;
|
|
402
|
+
for (const issue of analysis.dataFlowIssues.slice(0, 5)) {
|
|
403
|
+
const icon = issue.severity === 'critical' ? '❌' : '⚠️';
|
|
404
|
+
output += ` ${icon} ${issue.type} at "${issue.node}"\n`;
|
|
405
|
+
output += ` ${issue.description}\n\n`;
|
|
406
|
+
}
|
|
407
|
+
if (analysis.dataFlowIssues.length > 5) {
|
|
408
|
+
output += ` ... +${analysis.dataFlowIssues.length - 5} more\n`;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return output;
|
|
412
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Fixer
|
|
3
|
+
*
|
|
4
|
+
* Automatically fixes detected workflow issues including:
|
|
5
|
+
* - Orphan node removal
|
|
6
|
+
* - Email recipient rewiring to entity_extraction
|
|
7
|
+
* - Missing fallback addition
|
|
8
|
+
* - Multiple responder consolidation (NEW)
|
|
9
|
+
* - Ungated responder gating (NEW)
|
|
10
|
+
*/
|
|
11
|
+
import { detectWorkflowIssues } from "./knowledge.js";
|
|
12
|
+
import { detectMultipleResponders } from "./workflow-execution-analyzer.js";
|
|
13
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
// FIX FUNCTIONS
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
/**
|
|
17
|
+
* Remove orphan nodes (respecting protected nodes used by other fixes)
|
|
18
|
+
*/
|
|
19
|
+
function fixOrphanNodes(workflowDef, issues, protectedNodes) {
|
|
20
|
+
const fixes = [];
|
|
21
|
+
const orphanIssues = issues.filter(i => i.type === 'orphan');
|
|
22
|
+
if (orphanIssues.length === 0)
|
|
23
|
+
return { def: workflowDef, fixes };
|
|
24
|
+
// Get actions array (Ema format)
|
|
25
|
+
const actions = (workflowDef.actions || workflowDef.nodes || []);
|
|
26
|
+
const nodesToRemove = new Set();
|
|
27
|
+
for (const issue of orphanIssues) {
|
|
28
|
+
const nodeName = issue.node;
|
|
29
|
+
if (!nodeName)
|
|
30
|
+
continue;
|
|
31
|
+
if (protectedNodes.has(nodeName)) {
|
|
32
|
+
fixes.push({
|
|
33
|
+
applied: false,
|
|
34
|
+
issueType: 'orphan',
|
|
35
|
+
nodeName,
|
|
36
|
+
description: `Skipped - node used by another fix`,
|
|
37
|
+
});
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
nodesToRemove.add(nodeName);
|
|
41
|
+
fixes.push({
|
|
42
|
+
applied: true,
|
|
43
|
+
issueType: 'orphan',
|
|
44
|
+
nodeName,
|
|
45
|
+
description: `Removed orphan node "${nodeName}"`,
|
|
46
|
+
change: 'removed',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const filteredActions = actions.filter((a) => {
|
|
50
|
+
const action = a;
|
|
51
|
+
return !nodesToRemove.has(action.name || '');
|
|
52
|
+
});
|
|
53
|
+
const key = workflowDef.actions ? 'actions' : 'nodes';
|
|
54
|
+
return {
|
|
55
|
+
def: { ...workflowDef, [key]: filteredActions },
|
|
56
|
+
fixes,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Fix wrong email recipient sources - rewire to entity_extraction
|
|
61
|
+
*/
|
|
62
|
+
function fixWrongInputSource(workflowDef, issues) {
|
|
63
|
+
const fixes = [];
|
|
64
|
+
const usedNodes = new Set();
|
|
65
|
+
const wrongInputIssues = issues.filter(i => i.type === 'wrong_input_source');
|
|
66
|
+
if (wrongInputIssues.length === 0)
|
|
67
|
+
return { def: workflowDef, fixes, usedNodes };
|
|
68
|
+
const actions = (workflowDef.actions || workflowDef.nodes || []);
|
|
69
|
+
// Find entity extraction nodes
|
|
70
|
+
const entityExtractionNodes = actions.filter((a) => {
|
|
71
|
+
const action = a;
|
|
72
|
+
return action.action?.name?.name === 'entity_extraction' ||
|
|
73
|
+
action.action?.name?.name === 'entity_extraction_with_documents';
|
|
74
|
+
}).map((a) => a.name).filter(Boolean);
|
|
75
|
+
if (entityExtractionNodes.length === 0) {
|
|
76
|
+
fixes.push({
|
|
77
|
+
applied: false,
|
|
78
|
+
issueType: 'wrong_input_source',
|
|
79
|
+
nodeName: 'N/A',
|
|
80
|
+
description: `No entity_extraction node found to rewire email to`,
|
|
81
|
+
});
|
|
82
|
+
return { def: workflowDef, fixes, usedNodes };
|
|
83
|
+
}
|
|
84
|
+
const updatedActions = actions.map((a) => {
|
|
85
|
+
const action = a;
|
|
86
|
+
const nodeIssue = wrongInputIssues.find(i => i.node === action.name);
|
|
87
|
+
if (!nodeIssue)
|
|
88
|
+
return a;
|
|
89
|
+
const isEmailNode = ['send_email_agent', 'send_communications_handler'].includes(action.action?.name?.name || '');
|
|
90
|
+
if (!isEmailNode)
|
|
91
|
+
return a;
|
|
92
|
+
const inputs = { ...action.inputs };
|
|
93
|
+
let fixed = false;
|
|
94
|
+
for (const [inputName, binding] of Object.entries(inputs)) {
|
|
95
|
+
if (!['to_email', 'email_to', 'recipient'].includes(inputName.toLowerCase()))
|
|
96
|
+
continue;
|
|
97
|
+
const b = binding;
|
|
98
|
+
if (!b.actionOutput?.actionName)
|
|
99
|
+
continue;
|
|
100
|
+
const targetExtraction = entityExtractionNodes[0];
|
|
101
|
+
inputs[inputName] = {
|
|
102
|
+
actionOutput: {
|
|
103
|
+
actionName: targetExtraction,
|
|
104
|
+
output: 'extraction_columns',
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
usedNodes.add(targetExtraction);
|
|
108
|
+
fixed = true;
|
|
109
|
+
fixes.push({
|
|
110
|
+
applied: true,
|
|
111
|
+
issueType: 'wrong_input_source',
|
|
112
|
+
nodeName: action.name || '',
|
|
113
|
+
description: `Rewired ${inputName} to ${targetExtraction}.extraction_columns`,
|
|
114
|
+
change: `${inputName}: ${b.actionOutput.actionName} → ${targetExtraction}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (!fixed) {
|
|
118
|
+
fixes.push({
|
|
119
|
+
applied: false,
|
|
120
|
+
issueType: 'wrong_input_source',
|
|
121
|
+
nodeName: action.name || '',
|
|
122
|
+
description: `Could not find email_to field to rewire`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return { ...action, inputs };
|
|
126
|
+
});
|
|
127
|
+
const key = workflowDef.actions ? 'actions' : 'nodes';
|
|
128
|
+
return {
|
|
129
|
+
def: { ...workflowDef, [key]: updatedActions },
|
|
130
|
+
fixes,
|
|
131
|
+
usedNodes,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Add fallback category to categorizers missing one
|
|
136
|
+
*/
|
|
137
|
+
function fixMissingFallback(workflowDef, issues) {
|
|
138
|
+
const fixes = [];
|
|
139
|
+
const fallbackIssues = issues.filter(i => i.type === 'missing_fallback');
|
|
140
|
+
if (fallbackIssues.length === 0)
|
|
141
|
+
return { def: workflowDef, fixes };
|
|
142
|
+
const actions = (workflowDef.actions || workflowDef.nodes || []);
|
|
143
|
+
for (const issue of fallbackIssues) {
|
|
144
|
+
const categorizer = actions.find((a) => a.name === issue.node);
|
|
145
|
+
if (!categorizer?.action?.typeArguments?.categories)
|
|
146
|
+
continue;
|
|
147
|
+
const categories = categorizer.action.typeArguments.categories;
|
|
148
|
+
if (categories.some(c => c.toLowerCase() === 'fallback' || c.toLowerCase() === 'other')) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
categorizer.action.typeArguments.categories = [...categories, 'Fallback'];
|
|
152
|
+
fixes.push({
|
|
153
|
+
applied: true,
|
|
154
|
+
issueType: 'missing_fallback',
|
|
155
|
+
nodeName: issue.node || '',
|
|
156
|
+
description: `Added "Fallback" category`,
|
|
157
|
+
change: `categories += "Fallback"`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return { def: workflowDef, fixes };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* NEW: Fix ungated responders by adding runIf conditions
|
|
164
|
+
*/
|
|
165
|
+
function fixUngatedResponders(workflowDef, multiResponderIssues) {
|
|
166
|
+
const fixes = [];
|
|
167
|
+
const ungatedIssues = multiResponderIssues.filter(i => i.type === 'ungated_responder');
|
|
168
|
+
if (ungatedIssues.length === 0)
|
|
169
|
+
return { def: workflowDef, fixes };
|
|
170
|
+
const actions = (workflowDef.actions || workflowDef.nodes || []);
|
|
171
|
+
// Find categorizer to use for gating
|
|
172
|
+
const categorizer = actions.find((a) => {
|
|
173
|
+
const action = a;
|
|
174
|
+
return action.action?.name?.name === 'chat_categorizer';
|
|
175
|
+
});
|
|
176
|
+
if (!categorizer) {
|
|
177
|
+
fixes.push({
|
|
178
|
+
applied: false,
|
|
179
|
+
issueType: 'ungated_responder',
|
|
180
|
+
nodeName: 'N/A',
|
|
181
|
+
description: `No categorizer found to add gating condition from`,
|
|
182
|
+
});
|
|
183
|
+
return { def: workflowDef, fixes };
|
|
184
|
+
}
|
|
185
|
+
// For now, just report what SHOULD be done
|
|
186
|
+
for (const issue of ungatedIssues) {
|
|
187
|
+
for (const nodeId of issue.nodes) {
|
|
188
|
+
fixes.push({
|
|
189
|
+
applied: false, // Manual fix recommended
|
|
190
|
+
issueType: 'ungated_responder',
|
|
191
|
+
nodeName: nodeId,
|
|
192
|
+
description: `Should add runIf condition from "${categorizer.name}"`,
|
|
193
|
+
change: `Add: runIf: { enum: { enumType: "category", enumValue: "<specific_category>" } }`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { def: workflowDef, fixes };
|
|
198
|
+
}
|
|
199
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
200
|
+
// MAIN AUTO-FIX FUNCTION
|
|
201
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
202
|
+
export function autoFixWorkflow(workflowDef) {
|
|
203
|
+
const def = workflowDef;
|
|
204
|
+
const originalIssues = detectWorkflowIssues(workflowDef);
|
|
205
|
+
const multiResponderIssues = detectMultipleResponders(workflowDef);
|
|
206
|
+
const originalIssueCount = originalIssues.length + multiResponderIssues.length;
|
|
207
|
+
const allFixes = [];
|
|
208
|
+
const warnings = [];
|
|
209
|
+
let currentDef = { ...def };
|
|
210
|
+
// 1. Fix wrong input sources first (protects nodes)
|
|
211
|
+
const inputSourceResult = fixWrongInputSource(currentDef, originalIssues);
|
|
212
|
+
currentDef = inputSourceResult.def;
|
|
213
|
+
allFixes.push(...inputSourceResult.fixes);
|
|
214
|
+
const protectedNodes = inputSourceResult.usedNodes;
|
|
215
|
+
// 2. Fix missing fallback
|
|
216
|
+
const fallbackResult = fixMissingFallback(currentDef, originalIssues);
|
|
217
|
+
currentDef = fallbackResult.def;
|
|
218
|
+
allFixes.push(...fallbackResult.fixes);
|
|
219
|
+
// 3. Fix orphan nodes (respecting protected)
|
|
220
|
+
const orphanResult = fixOrphanNodes(currentDef, originalIssues, protectedNodes);
|
|
221
|
+
currentDef = orphanResult.def;
|
|
222
|
+
allFixes.push(...orphanResult.fixes);
|
|
223
|
+
// 4. NEW: Report ungated responder fixes needed
|
|
224
|
+
const ungatedResult = fixUngatedResponders(currentDef, multiResponderIssues);
|
|
225
|
+
const multipleResponderFixes = ungatedResult.fixes;
|
|
226
|
+
// Re-detect remaining issues
|
|
227
|
+
const remainingIssues = detectWorkflowIssues(currentDef);
|
|
228
|
+
const remainingMultiResponder = detectMultipleResponders(currentDef);
|
|
229
|
+
const remainingIssueCount = remainingIssues.length + remainingMultiResponder.length;
|
|
230
|
+
const appliedCount = allFixes.filter(f => f.applied).length;
|
|
231
|
+
if (multipleResponderFixes.length > 0) {
|
|
232
|
+
warnings.push(`${multipleResponderFixes.length} ungated responder(s) need manual gating to prevent duplicate responses`);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
originalIssueCount,
|
|
236
|
+
fixesApplied: allFixes,
|
|
237
|
+
remainingIssueCount,
|
|
238
|
+
workflowDef: currentDef,
|
|
239
|
+
success: appliedCount > 0,
|
|
240
|
+
warnings,
|
|
241
|
+
multipleResponderFixes,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Suggest fixes without applying (dry run)
|
|
246
|
+
*/
|
|
247
|
+
export function suggestFixes(workflowDef) {
|
|
248
|
+
const issues = detectWorkflowIssues(workflowDef);
|
|
249
|
+
const multiResponderIssues = detectMultipleResponders(workflowDef);
|
|
250
|
+
const standardFixes = [];
|
|
251
|
+
const multiResponderFixes = [];
|
|
252
|
+
for (const issue of issues) {
|
|
253
|
+
if (!issue.auto_fixable)
|
|
254
|
+
continue;
|
|
255
|
+
standardFixes.push({
|
|
256
|
+
applied: false,
|
|
257
|
+
issueType: issue.type,
|
|
258
|
+
nodeName: issue.node || 'N/A',
|
|
259
|
+
description: issue.reason || `Fix ${issue.type}`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
for (const issue of multiResponderIssues) {
|
|
263
|
+
multiResponderFixes.push({
|
|
264
|
+
applied: false,
|
|
265
|
+
issueType: issue.type,
|
|
266
|
+
nodeName: issue.nodes.join(', '),
|
|
267
|
+
description: issue.description,
|
|
268
|
+
change: issue.fixSuggestion,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return { standardFixes, multiResponderFixes };
|
|
272
|
+
}
|