@ema.co/mcp-toolkit 2026.2.19 → 2026.2.23-2

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.

Files changed (43) hide show
  1. package/dist/cli/index.js +2 -2
  2. package/dist/mcp/domain/loop-detection.js +46 -54
  3. package/dist/mcp/domain/sanitizer.js +1 -1
  4. package/dist/mcp/domain/workflow-graph-optimizer.js +96 -14
  5. package/dist/mcp/domain/workflow-graph-transforms.js +50 -54
  6. package/dist/mcp/domain/workflow-graph.js +41 -19
  7. package/dist/mcp/domain/workflow-path-enumerator.js +7 -4
  8. package/dist/mcp/guidance.js +53 -0
  9. package/dist/mcp/handlers/debug/adapter.js +15 -0
  10. package/dist/mcp/handlers/debug/formatters.js +282 -0
  11. package/dist/mcp/handlers/debug/index.js +133 -0
  12. package/dist/mcp/handlers/demo/adapter.js +180 -0
  13. package/dist/mcp/handlers/env/config.js +2 -2
  14. package/dist/mcp/handlers/index.js +0 -1
  15. package/dist/mcp/handlers/persona/adapter.js +135 -0
  16. package/dist/mcp/handlers/sync/adapter.js +200 -0
  17. package/dist/mcp/handlers/workflow/adapter.js +177 -0
  18. package/dist/mcp/handlers/workflow/index.js +0 -24
  19. package/dist/mcp/handlers/workflow/optimize.js +9 -5
  20. package/dist/mcp/knowledge-guidance-topics.js +615 -0
  21. package/dist/mcp/knowledge.js +23 -612
  22. package/dist/mcp/resources-dynamic.js +2395 -0
  23. package/dist/mcp/resources-validation.js +408 -0
  24. package/dist/mcp/resources.js +72 -2724
  25. package/dist/mcp/server.js +33 -832
  26. package/dist/mcp/tools.js +104 -2
  27. package/dist/sdk/client-adapter.js +265 -24
  28. package/dist/sdk/ema-client.js +100 -9
  29. package/dist/sdk/generated/api-client/client/client.gen.js +3 -3
  30. package/dist/sdk/generated/api-client/client/index.js +5 -5
  31. package/dist/sdk/generated/api-client/client/utils.gen.js +4 -4
  32. package/dist/sdk/generated/api-client/client.gen.js +1 -1
  33. package/dist/sdk/generated/api-client/core/utils.gen.js +1 -1
  34. package/dist/sdk/generated/api-client/index.js +1 -1
  35. package/dist/sdk/generated/api-client/sdk.gen.js +2 -2
  36. package/dist/sdk/generated/well-known-types.js +99 -0
  37. package/dist/sdk/grpc-client.js +115 -1
  38. package/dist/sync/sdk.js +2 -2
  39. package/dist/sync.js +4 -3
  40. package/package.json +4 -3
  41. package/dist/mcp/domain/workflow-optimizer.js +0 -671
  42. package/dist/mcp/handlers/knowledge/index.js +0 -54
  43. package/dist/mcp/handlers/workflow/fix.js +0 -588
package/dist/cli/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * config validate - Validate config file
12
12
  */
13
13
  import { loadConfig } from "../sdk/config.js";
14
- import { EmaClient } from "../sdk/client.js";
14
+ import { EmaClientAdapter } from "../sdk/client-adapter.js";
15
15
  import { SyncSDK } from "../sync/sdk.js";
16
16
  function printUsage() {
17
17
  console.log(`
@@ -220,7 +220,7 @@ async function runAgentsCommand(subcommand, args, options) {
220
220
  baseUrl: master.baseUrl,
221
221
  bearerToken: getEnvOrThrow(master.bearerTokenEnv),
222
222
  };
223
- const client = new EmaClient(env);
223
+ const client = new EmaClientAdapter(env);
224
224
  switch (subcommand) {
225
225
  case "list": {
226
226
  const actions = await client.listAgents();
@@ -1,31 +1,18 @@
1
1
  /**
2
2
  * Loop Detection for Workflow Graphs
3
3
  *
4
- * Extracted from workflow-execution-analyzer.ts — only the parts
5
- * used by live validation code (detectLoops + LoopInfo).
4
+ * Uses the shared WorkflowGraph representation from workflow-graph.ts
5
+ * for cycle and re-entry detection in workflow validation.
6
6
  */
7
- import { parseWorkflowDef } from "../knowledge.js";
8
- function buildGraphMaps(nodes) {
9
- const forward = new Map();
10
- const nodeMap = new Map();
11
- for (const node of nodes) {
12
- nodeMap.set(node.id, node);
13
- forward.set(node.id, new Set());
14
- }
15
- for (const node of nodes) {
16
- if (node.incoming_edges) {
17
- for (const edge of node.incoming_edges) {
18
- const sourceId = edge.source_node_id;
19
- forward.get(sourceId)?.add(node.id);
20
- }
21
- }
22
- }
23
- return { forward, nodeMap };
24
- }
7
+ import { buildWorkflowGraph } from "./workflow-graph.js";
25
8
  export function detectLoops(workflowDef) {
26
- const nodes = parseWorkflowDef(workflowDef);
9
+ if (!workflowDef || typeof workflowDef !== "object")
10
+ return [];
11
+ const graph = buildWorkflowGraph(workflowDef);
12
+ if (graph.nodes.size === 0)
13
+ return [];
27
14
  const loops = [];
28
- const { forward, nodeMap } = buildGraphMaps(nodes);
15
+ const { forward } = graph;
29
16
  const visited = new Set();
30
17
  const recursionStack = new Set();
31
18
  const path = [];
@@ -33,7 +20,7 @@ export function detectLoops(workflowDef) {
33
20
  visited.add(nodeId);
34
21
  recursionStack.add(nodeId);
35
22
  path.push(nodeId);
36
- for (const neighbor of (forward.get(nodeId) || [])) {
23
+ for (const neighbor of (forward.get(nodeId) ?? [])) {
37
24
  if (!visited.has(neighbor)) {
38
25
  if (dfs(neighbor))
39
26
  return true;
@@ -56,42 +43,47 @@ export function detectLoops(workflowDef) {
56
43
  recursionStack.delete(nodeId);
57
44
  return false;
58
45
  }
59
- const trigger = nodes.find(n => n.action_name === 'trigger' || n.id === 'trigger');
60
- if (trigger)
61
- dfs(trigger.id);
62
- for (const node of nodes) {
63
- if (!visited.has(node.id))
64
- dfs(node.id);
46
+ if (graph.trigger)
47
+ dfs(graph.trigger);
48
+ for (const nodeId of graph.nodes.keys()) {
49
+ if (!visited.has(nodeId))
50
+ dfs(nodeId);
65
51
  }
66
52
  // Detect categorizer routing back to upstream (re-entry)
67
- for (const node of nodes) {
68
- if (node.action_name === 'chat_categorizer') {
69
- const downstream = forward.get(node.id) || new Set();
70
- const upstream = new Set();
71
- function findUpstream(nId, depth = 0) {
72
- if (depth > 20)
73
- return;
74
- const nodeInfo = nodeMap.get(nId);
75
- if (!nodeInfo?.incoming_edges)
76
- return;
77
- for (const edge of nodeInfo.incoming_edges) {
78
- upstream.add(edge.source_node_id);
79
- findUpstream(edge.source_node_id, depth + 1);
53
+ detectReentryLoops(graph, loops);
54
+ return loops;
55
+ }
56
+ /**
57
+ * Detect categorizer nodes routing back to upstream nodes (re-entry risk).
58
+ * Uses the graph's reverse adjacency map instead of manual edge traversal.
59
+ */
60
+ function detectReentryLoops(graph, loops) {
61
+ for (const [nodeId, graphNode] of graph.nodes) {
62
+ if (graphNode.actionType !== 'chat_categorizer')
63
+ continue;
64
+ const downstream = graph.forward.get(nodeId) ?? new Set();
65
+ const upstream = new Set();
66
+ function collectUpstream(nId, depth = 0) {
67
+ if (depth > 20)
68
+ return;
69
+ for (const parent of (graph.reverse.get(nId) ?? [])) {
70
+ if (!upstream.has(parent)) {
71
+ upstream.add(parent);
72
+ collectUpstream(parent, depth + 1);
80
73
  }
81
74
  }
82
- findUpstream(node.id);
83
- for (const downNode of downstream) {
84
- if (upstream.has(downNode)) {
85
- loops.push({
86
- type: 'reentry_loop',
87
- nodes: [node.id, downNode],
88
- description: `Categorizer "${node.display_name || node.id}" routes to "${downNode}" which is upstream - can cause re-processing`,
89
- severity: 'warning',
90
- fixSuggestion: `Add state tracking or one-time-execution gate to prevent re-processing.`,
91
- });
92
- }
75
+ }
76
+ collectUpstream(nodeId);
77
+ for (const downNode of downstream) {
78
+ if (upstream.has(downNode)) {
79
+ loops.push({
80
+ type: 'reentry_loop',
81
+ nodes: [nodeId, downNode],
82
+ description: `Categorizer "${graphNode.displayName || nodeId}" routes to "${downNode}" which is upstream - can cause re-processing`,
83
+ severity: 'warning',
84
+ fixSuggestion: `Add state tracking or one-time-execution gate to prevent re-processing.`,
85
+ });
93
86
  }
94
87
  }
95
88
  }
96
- return loops;
97
89
  }
@@ -896,7 +896,7 @@ export function applySessionMappings(text, session) {
896
896
  * Content fields that SHOULD be sanitized (allowlist approach).
897
897
  * Only these fields contain user-facing content that may have PII.
898
898
  *
899
- * TODO: Generate this from OpenAPI spec or gRPC definitions
899
+ * Manually maintained candidate for auto-generation from OpenAPI/gRPC definitions.
900
900
  */
901
901
  const CONTENT_FIELDS = new Set([
902
902
  // Persona-level
@@ -12,7 +12,7 @@ import { AUTO_TRANSFORMS, ADVISORY_TRANSFORMS, } from "./workflow-graph-transfor
12
12
  function deepClone(obj) {
13
13
  return JSON.parse(JSON.stringify(obj));
14
14
  }
15
- function calculateMetrics(workflowDef, graph) {
15
+ function calculateMetrics(graph) {
16
16
  const nodeCount = graph.nodes.size;
17
17
  const edgeCount = graph.edges.length;
18
18
  // Dead nodes: total - reachable from trigger
@@ -50,6 +50,83 @@ function generateImprovementSummary(before, after) {
50
50
  summary += `. ${before.nodeCount} \u2192 ${after.nodeCount} nodes.`;
51
51
  return summary;
52
52
  }
53
+ /**
54
+ * Show the immediate neighborhood (parents → node → children) of a set of nodes
55
+ * from the graph state BEFORE a transform ran. Used to explain why nodes were
56
+ * classified as dead/trailing so the viewer can verify the reasoning.
57
+ *
58
+ * Example output:
59
+ * orphaned_search (search)
60
+ * ← (no parents — unreachable from trigger)
61
+ * → respond_unused
62
+ * respond_unused (respond_for_external_actions)
63
+ * ← orphaned_search
64
+ * → (no children — does not reach any output)
65
+ */
66
+ function buildNodeNeighborhood(nodeIds, graph) {
67
+ if (nodeIds.length === 0)
68
+ return undefined;
69
+ const resultNodeIds = new Set(graph.outputMappings.map((m) => m.nodeId));
70
+ const lines = [];
71
+ for (const nodeId of nodeIds) {
72
+ const node = graph.nodes.get(nodeId);
73
+ if (!node)
74
+ continue;
75
+ const parents = [...(graph.reverse.get(nodeId) ?? [])];
76
+ const children = [...(graph.forward.get(nodeId) ?? [])];
77
+ const parentStr = parents.length > 0
78
+ ? parents.join(" · ")
79
+ : "(no parents)";
80
+ const childStr = children.length > 0
81
+ ? children.map((c) => resultNodeIds.has(c) ? `${c} [→OUTPUT]` : c).join(" · ")
82
+ : "(no children — does not reach any output)";
83
+ lines.push(` ${nodeId} (${node.actionType})`);
84
+ lines.push(` ← ${parentStr}`);
85
+ lines.push(` → ${childStr}`);
86
+ }
87
+ return lines.join("\n") || undefined;
88
+ }
89
+ /**
90
+ * Build a compact edge-list flow diagram of the workflow graph.
91
+ *
92
+ * Lists nodes in topological order (or declaration order for triggerless workflows),
93
+ * showing depth, type, children, and which nodes map to outputs.
94
+ */
95
+ function buildFlowDiagram(graph) {
96
+ if (graph.nodes.size === 0)
97
+ return "(empty workflow — no nodes)";
98
+ const resultNodeIds = new Set(graph.outputMappings.map((m) => m.nodeId));
99
+ const lines = [];
100
+ // Header line
101
+ const triggerNote = graph.trigger
102
+ ? `entry: ${graph.trigger}`
103
+ : "no recognized trigger (dashboard/document workflow — all nodes kept)";
104
+ lines.push(`${graph.nodes.size} nodes · ${graph.edges.length} edges · ${triggerNote}`);
105
+ // Topological order for consistent depth-first traversal; fall back to declaration order
106
+ const topoOrder = getTopologicalOrder(graph);
107
+ const nodeOrder = topoOrder.length === graph.nodes.size ? topoOrder : [...graph.nodes.keys()];
108
+ for (const nodeId of nodeOrder) {
109
+ const node = graph.nodes.get(nodeId);
110
+ if (!node)
111
+ continue;
112
+ const children = [...(graph.forward.get(nodeId) ?? [])];
113
+ const tags = [];
114
+ if (node.isTrigger)
115
+ tags.push("ENTRY");
116
+ if (resultNodeIds.has(nodeId))
117
+ tags.push("→OUTPUT");
118
+ if (node.hasSideEffects && !node.isTrigger)
119
+ tags.push("side-effect");
120
+ const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
121
+ // Indent up to 6 levels to avoid runaway indentation on deep graphs
122
+ const indent = " ".repeat(Math.min(node.depth, 6));
123
+ lines.push(`${indent}${nodeId} (${node.actionType})${tagStr}`);
124
+ if (children.length > 0) {
125
+ lines.push(`${indent} └─► ${children.join(" · ")}`);
126
+ }
127
+ }
128
+ return lines.join("\n");
129
+ }
53
130
  function validateWorkflow(workflowDef) {
54
131
  const errors = [];
55
132
  const warnings = [];
@@ -97,7 +174,7 @@ export function optimizeWorkflow(workflowDef, options) {
97
174
  // Graceful handling of invalid input
98
175
  if (!workflowDef || typeof workflowDef !== "object") {
99
176
  return {
100
- workflowDef: workflowDef ?? {},
177
+ workflowDef: (workflowDef ?? {}),
101
178
  modified: false,
102
179
  appliedTransforms: [],
103
180
  advisories: [],
@@ -107,6 +184,7 @@ export function optimizeWorkflow(workflowDef, options) {
107
184
  improvement: "No changes needed.",
108
185
  },
109
186
  validation: { valid: false, errors: ["Invalid workflow_def input"], warnings: [] },
187
+ flow_diagram: "(no workflow_def provided)",
110
188
  };
111
189
  }
112
190
  try {
@@ -114,14 +192,16 @@ export function optimizeWorkflow(workflowDef, options) {
114
192
  let currentWorkflowDef = deepClone(workflowDef);
115
193
  // Step 2: Calculate "before" metrics
116
194
  let graph = buildWorkflowGraph(currentWorkflowDef);
117
- const beforeMetrics = calculateMetrics(currentWorkflowDef, graph);
195
+ const beforeMetrics = calculateMetrics(graph);
118
196
  // Step 3: Auto-apply transforms (convergence loop)
197
+ // Captures pass number and before-graph neighborhood for each change.
119
198
  const appliedTransforms = [];
120
199
  if (autoApply && maxPasses > 0) {
121
200
  for (let pass = 0; pass < maxPasses; pass++) {
122
201
  let anyApplied = false;
123
202
  for (const transform of AUTO_TRANSFORMS) {
124
203
  try {
204
+ const graphBefore = graph; // snapshot before this transform
125
205
  const result = transform.apply(currentWorkflowDef, graph);
126
206
  if (result && result.applied) {
127
207
  currentWorkflowDef = result.workflowDef;
@@ -131,6 +211,12 @@ export function optimizeWorkflow(workflowDef, options) {
131
211
  description: result.description,
132
212
  nodesRemoved: result.nodesRemoved,
133
213
  nodesModified: result.nodesModified,
214
+ pass: pass + 1,
215
+ // Show neighborhood of affected nodes in the before-graph so the
216
+ // viewer can verify why each node was classified as removable.
217
+ flow_context: result.nodesRemoved.length > 0
218
+ ? buildNodeNeighborhood(result.nodesRemoved, graphBefore)
219
+ : undefined,
134
220
  });
135
221
  anyApplied = true;
136
222
  }
@@ -143,17 +229,15 @@ export function optimizeWorkflow(workflowDef, options) {
143
229
  break; // Converged
144
230
  }
145
231
  }
146
- // Step 4: Calculate "after" metrics
232
+ // Step 4: Calculate "after" metrics and rebuild final graph
147
233
  graph = buildWorkflowGraph(currentWorkflowDef);
148
- const afterMetrics = calculateMetrics(currentWorkflowDef, graph);
234
+ const afterMetrics = calculateMetrics(graph);
149
235
  // Step 5: Run advisory transforms
150
236
  const advisories = [];
151
237
  for (const transform of ADVISORY_TRANSFORMS) {
152
238
  try {
153
239
  const result = transform.apply(currentWorkflowDef, graph);
154
240
  if (result && !result.applied) {
155
- // Advisory: applied=false means it's a finding, not a fix
156
- // Extract affected nodes from the description or result fields
157
241
  const affectedNodes = extractAffectedNodes(result);
158
242
  advisories.push({
159
243
  id: transform.id,
@@ -166,21 +250,18 @@ export function optimizeWorkflow(workflowDef, options) {
166
250
  // Advisory failure: skip
167
251
  }
168
252
  }
169
- // Step 6: Generate improvement summary
253
+ // Step 6: Generate improvement summary + validation + flow diagram
170
254
  const improvement = generateImprovementSummary(beforeMetrics, afterMetrics);
171
- // Step 7: Post-optimization validation
172
255
  const validation = validateWorkflow(currentWorkflowDef);
256
+ const flow_diagram = buildFlowDiagram(graph);
173
257
  return {
174
258
  workflowDef: currentWorkflowDef,
175
259
  modified: appliedTransforms.length > 0,
176
260
  appliedTransforms,
177
261
  advisories,
178
- metrics: {
179
- before: beforeMetrics,
180
- after: afterMetrics,
181
- improvement,
182
- },
262
+ metrics: { before: beforeMetrics, after: afterMetrics, improvement },
183
263
  validation,
264
+ flow_diagram,
184
265
  };
185
266
  }
186
267
  catch {
@@ -196,6 +277,7 @@ export function optimizeWorkflow(workflowDef, options) {
196
277
  improvement: "No changes needed.",
197
278
  },
198
279
  validation: { valid: false, errors: ["Optimization failed unexpectedly"], warnings: [] },
280
+ flow_diagram: "(optimization failed)",
199
281
  };
200
282
  }
201
283
  }
@@ -66,32 +66,46 @@ function nodeSignature(action) {
66
66
  }
67
67
  return JSON.stringify({ actionType, version, inputWiring });
68
68
  }
69
- /** Check if an action has side effects based on its type. */
70
- function hasSideEffects(action) {
71
- const actionType = extractActionType(action);
72
- const SIDE_EFFECT_FREE = new Set([
73
- "search", "call_llm", "entity_extraction", "respond_with_sources",
74
- "respond_for_external_actions", "conversation_to_search_query",
75
- "combine_search_results", "text_categorizer", "chat_categorizer",
76
- "response_validator", "fixed_response", "live_web_search",
77
- "ai_web_search", "json_mapper", "custom_agent",
78
- ]);
79
- const TRIGGER_TYPES = new Set(["chat_trigger", "voice_trigger", "document_trigger"]);
80
- if (TRIGGER_TYPES.has(actionType))
81
- return false;
82
- if (SIDE_EFFECT_FREE.has(actionType))
83
- return false;
84
- return true;
85
- }
86
- /** Check if an action is a trigger node. */
87
- function isTriggerAction(action) {
88
- const actionType = extractActionType(action);
89
- return actionType === "chat_trigger" || actionType === "voice_trigger" || actionType === "document_trigger";
90
- }
91
69
  /** Get all node names from actions array. */
92
70
  function getActionNames(actions) {
93
71
  return new Set(actions.map((a) => a.name).filter(Boolean));
94
72
  }
73
+ /**
74
+ * Recursively rewire actionOutput references from oldName → newName in a binding.
75
+ * Mirrors extractEdgesFromBinding in workflow-graph.ts: handles the same nesting
76
+ * depth so graph construction and reference rewiring stay symmetric.
77
+ *
78
+ * Handles:
79
+ * actionOutput → base case: mutate actionName if matches
80
+ * multiBinding → recurse into elements[]
81
+ * namedBinding → recurse into .value
82
+ */
83
+ function rewireBinding(binding, oldName, newName) {
84
+ if (!binding || typeof binding !== "object")
85
+ return;
86
+ if (binding.actionOutput) {
87
+ const ao = binding.actionOutput;
88
+ if (ao.actionName === oldName) {
89
+ ao.actionName = newName;
90
+ }
91
+ return;
92
+ }
93
+ if (binding.multiBinding) {
94
+ const mb = binding.multiBinding;
95
+ const elements = mb.elements ?? [];
96
+ for (const elem of elements) {
97
+ rewireBinding(elem, oldName, newName);
98
+ }
99
+ return;
100
+ }
101
+ if (binding.namedBinding) {
102
+ const nb = binding.namedBinding;
103
+ const value = nb.value;
104
+ if (value) {
105
+ rewireBinding(value, oldName, newName);
106
+ }
107
+ }
108
+ }
95
109
  /**
96
110
  * Rewire all references from oldName to newName in the actions array.
97
111
  * This updates actionOutput bindings, multiBinding refs, and runIf refs.
@@ -101,35 +115,7 @@ function rewireReferences(actions, oldName, newName) {
101
115
  const inputs = action.inputs;
102
116
  if (inputs) {
103
117
  for (const val of Object.values(inputs)) {
104
- const binding = val;
105
- if (binding?.actionOutput) {
106
- const ao = binding.actionOutput;
107
- if (ao.actionName === oldName) {
108
- ao.actionName = newName;
109
- }
110
- }
111
- if (binding?.multiBinding) {
112
- const mb = binding.multiBinding;
113
- const elements = mb.elements ?? [];
114
- for (const elem of elements) {
115
- if (elem.namedBinding) {
116
- const nb = elem.namedBinding;
117
- const value = nb.value;
118
- if (value?.actionOutput) {
119
- const ao = value.actionOutput;
120
- if (ao.actionName === oldName) {
121
- ao.actionName = newName;
122
- }
123
- }
124
- }
125
- else if (elem.actionOutput) {
126
- const ao = elem.actionOutput;
127
- if (ao.actionName === oldName) {
128
- ao.actionName = newName;
129
- }
130
- }
131
- }
132
- }
118
+ rewireBinding(val, oldName, newName);
133
119
  }
134
120
  }
135
121
  // Rewire runIf
@@ -187,7 +173,7 @@ const removeDeadNodes = {
187
173
  const node = graph.nodes.get(nodeId);
188
174
  const hasDisplayName = node.displayName && node.displayName !== nodeId;
189
175
  const isReferencedInResults = Object.values(results).some((r) => r.actionName === nodeId);
190
- const hasActionOutputInputs = [...node.inputs.values()].some((ref) => ref.type === "action_output");
176
+ const hasActionOutputInputs = [...node.inputs.values()].some((ref) => ref.type === "action_output" || ref.type === "multi_binding");
191
177
  if (hasDisplayName || isReferencedInResults || hasActionOutputInputs) {
192
178
  // Intentional node: skip removal (could attempt wiring, but
193
179
  // wiring heuristics are risky so we just keep it)
@@ -310,9 +296,10 @@ const mergeIdenticalNodes = {
310
296
  // Group by signature, skipping triggers and side-effect nodes
311
297
  const groups = new Map();
312
298
  for (const action of actions) {
313
- if (isTriggerAction(action))
299
+ const graphNode = graph.nodes.get(action.name);
300
+ if (graphNode?.isTrigger)
314
301
  continue;
315
- if (hasSideEffects(action))
302
+ if (graphNode?.hasSideEffects ?? true)
316
303
  continue;
317
304
  const sig = nodeSignature(action);
318
305
  if (!groups.has(sig))
@@ -521,6 +508,15 @@ const removeTrailingNodes = {
521
508
  try {
522
509
  if (graph.nodes.size === 0)
523
510
  return null;
511
+ // Dashboard/document workflows have no recognized trigger action.
512
+ // Without a trigger, every supplier node (which has no forward path to a result
513
+ // because it IS an upstream input) would be incorrectly classified as trailing.
514
+ if (!graph.trigger)
515
+ return null;
516
+ // Without any result mappings we cannot identify what contributes to output,
517
+ // so every non-trigger side-effect-free node would appear trailing.
518
+ if (graph.outputMappings.length === 0)
519
+ return null;
524
520
  // Build set of nodes that contribute to output mappings
525
521
  const resultNodes = new Set(graph.outputMappings.map((m) => m.nodeId));
526
522
  // Find nodes that are downstream of result nodes but don't contribute to results
@@ -4,8 +4,8 @@
4
4
  * Single source of truth for parsing workflow_def JSON into a typed graph.
5
5
  * Used by the workflow optimizer for static analysis.
6
6
  *
7
- * NOTE: `loop-detection.ts` has its own `buildGraphMaps()` for validation
8
- * (cycle/re-entry detection). Future work: consolidate both into this module.
7
+ * `loop-detection.ts` uses `buildWorkflowGraph()` from this module for
8
+ * cycle and re-entry detection (consolidated from a prior local `buildGraphMaps`).
9
9
  */
10
10
  // ─────────────────────────────────────────────────────────────────────────────
11
11
  // Constants
@@ -117,23 +117,8 @@ export function buildWorkflowGraph(workflowDef) {
117
117
  }
118
118
  else if (binding.multiBinding) {
119
119
  inputRefs.set(inputName, { type: "multi_binding" });
120
- // Parse elements for nested actionOutput refs
121
- const mb = binding.multiBinding;
122
- const elements = mb.elements ?? [];
123
- for (const elem of elements) {
124
- if (elem.namedBinding) {
125
- const nb = elem.namedBinding;
126
- const value = nb.value;
127
- if (value?.actionOutput) {
128
- const ao = value.actionOutput;
129
- addEdge(edges, forward, reverse, ao.actionName, nodeId, ao.output, inputName);
130
- }
131
- }
132
- else if (elem.actionOutput) {
133
- const ao = elem.actionOutput;
134
- addEdge(edges, forward, reverse, ao.actionName, nodeId, ao.output, inputName);
135
- }
136
- }
120
+ // Recursively traverse nested multiBinding/namedBinding structures to find all actionOutput refs
121
+ extractEdgesFromBinding(binding, nodeId, inputName, edges, forward, reverse);
137
122
  }
138
123
  else if (binding.widgetConfig) {
139
124
  inputRefs.set(inputName, { type: "widget" });
@@ -363,6 +348,43 @@ function extractEnumTypeName(et) {
363
348
  }
364
349
  return undefined;
365
350
  }
351
+ /**
352
+ * Recursively extract actionOutput edges from an arbitrarily nested binding structure.
353
+ *
354
+ * Handles:
355
+ * actionOutput → base case: emit edge
356
+ * multiBinding → recurse into elements[]
357
+ * namedBinding → recurse into .value
358
+ *
359
+ * This covers nested proto patterns like:
360
+ * { multiBinding: { elements: [{ namedBinding: { value: { multiBinding: { elements: [{ actionOutput: {...} }] } } } }] } }
361
+ */
362
+ function extractEdgesFromBinding(binding, targetNode, targetInput, edges, forward, reverse) {
363
+ if (!binding || typeof binding !== "object")
364
+ return;
365
+ if (binding.actionOutput) {
366
+ const ao = binding.actionOutput;
367
+ if (ao.actionName) {
368
+ addEdge(edges, forward, reverse, ao.actionName, targetNode, ao.output ?? "", targetInput);
369
+ }
370
+ return;
371
+ }
372
+ if (binding.multiBinding) {
373
+ const mb = binding.multiBinding;
374
+ const elements = mb.elements ?? [];
375
+ for (const elem of elements) {
376
+ extractEdgesFromBinding(elem, targetNode, targetInput, edges, forward, reverse);
377
+ }
378
+ return;
379
+ }
380
+ if (binding.namedBinding) {
381
+ const nb = binding.namedBinding;
382
+ const value = nb.value;
383
+ if (value) {
384
+ extractEdgesFromBinding(value, targetNode, targetInput, edges, forward, reverse);
385
+ }
386
+ }
387
+ }
366
388
  function addEdge(edges, forward, reverse, source, target, sourceOutput, targetInput) {
367
389
  edges.push({ source, target, sourceOutput, targetInput });
368
390
  if (!forward.has(source))
@@ -50,7 +50,7 @@ export function enumeratePaths(workflow, options) {
50
50
  throw new Error("Branch outcome without branch_point");
51
51
  }
52
52
  for (const value of result.branch_point.possible_values) {
53
- const forkedSession = forkSessionForBranching(session, result.branch_point, value);
53
+ const forkedSession = forkSessionForBranching(session, result.branch_point, value, workflow);
54
54
  sessionsToProcess.push(forkedSession);
55
55
  }
56
56
  }
@@ -80,7 +80,7 @@ function createInitialSession(workflow) {
80
80
  action_states: new Map(),
81
81
  };
82
82
  }
83
- function forkSessionForBranching(session, branchInfo, chosenValue) {
83
+ function forkSessionForBranching(session, branchInfo, chosenValue, workflow) {
84
84
  // Deep copy session
85
85
  const forked = {
86
86
  completed_actions: [...session.completed_actions],
@@ -102,8 +102,11 @@ function forkSessionForBranching(session, branchInfo, chosenValue) {
102
102
  node_id: branchInfo.action_that_branched,
103
103
  output_name: branchInfo.branching_output_name,
104
104
  });
105
- // TODO: Generate dummy outputs for other outputs of branching action
106
- // TODO: Generate dummy outputs for non-branching actions
105
+ // Add remaining outputs from the branching action (e.g. categorizer also produces confidence)
106
+ const branchingNode = workflow.nodes?.find(n => n.id === branchInfo.action_that_branched);
107
+ if (branchingNode) {
108
+ addActionOutputs(forked, branchingNode);
109
+ }
107
110
  return forked;
108
111
  }
109
112
  // ─────────────────────────────────────────────────────────────────────────────