@ema.co/mcp-toolkit 2026.1.27 → 2026.1.28-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 (35) hide show
  1. package/dist/mcp/handlers/data/index.js +3 -0
  2. package/dist/mcp/handlers/persona/create.js +16 -0
  3. package/dist/mcp/handlers/persona/list.js +9 -4
  4. package/dist/mcp/handlers/persona/update.js +24 -2
  5. package/dist/mcp/handlers/workflow/deploy.js +20 -2
  6. package/dist/mcp/handlers/workflow/generate.js +39 -2
  7. package/dist/mcp/handlers/workflow/index.js +8 -3
  8. package/dist/mcp/handlers/workflow/modify.js +34 -7
  9. package/dist/mcp/handlers/workflow/validate.js +85 -0
  10. package/dist/mcp/handlers/workflow/validation.js +160 -0
  11. package/dist/mcp/resources.js +286 -4
  12. package/dist/mcp/server.js +16 -3
  13. package/dist/mcp/tools.js +32 -11
  14. package/dist/sdk/client.js +36 -9
  15. package/dist/sdk/ema-client.js +32 -4
  16. package/dist/sdk/index.js +3 -1
  17. package/dist/sdk/knowledge.js +5 -5
  18. package/dist/sdk/structural-rules.js +498 -0
  19. package/dist/sdk/workflow-generator.js +2 -1
  20. package/dist/sdk/workflow-intent.js +28 -96
  21. package/dist/sdk/workflow-path-enumerator.js +278 -0
  22. package/dist/sdk/workflow-static-validator.js +291 -0
  23. package/dist/sdk/workflow-validation-types.js +7 -0
  24. package/docs/README.md +14 -0
  25. package/docs/go-validator-analysis.md +323 -0
  26. package/docs/rule-format-specification.md +346 -0
  27. package/docs/validation-contract.md +397 -0
  28. package/docs/validation-error-format.md +326 -0
  29. package/package.json +1 -1
  30. package/dist/mcp/workflow-operations.js +0 -100
  31. package/dist/sdk/workflow-fixer.js +0 -48
  32. package/docs/dashboard-operations.md +0 -281
  33. package/docs/ema-user-guide.md +0 -1201
  34. package/docs/email-patterns.md +0 -120
  35. package/docs/mcp-tools-guide.md +0 -575
@@ -162,6 +162,9 @@ export async function handleData(args, client, readFile) {
162
162
  path: filePath,
163
163
  widget_name: widgetName ?? "fileUpload",
164
164
  ...result,
165
+ _warning: "IMPORTANT: Uploaded documents will NOT be used unless your workflow has a search node (search/v2).",
166
+ _next_step: `Verify workflow has search: workflow(mode='get', persona_id='${personaId}') → check for search node. If missing, add one.`,
167
+ _validation: "Deploy will BLOCK if you have documents but no search node in your workflow.",
165
168
  };
166
169
  }
167
170
  catch (error) {
@@ -221,6 +221,7 @@ export async function handleCreate(args, client, getTemplateId) {
221
221
  sourcePersonaType,
222
222
  dashboardCloneResult,
223
223
  actionsError: validation.errors.join("; "),
224
+ createdFromTemplate: fromType === "template",
224
225
  });
225
226
  }
226
227
  // Build execution context
@@ -243,6 +244,7 @@ export async function handleCreate(args, client, getTemplateId) {
243
244
  sourcePersonaType,
244
245
  // Don't include dashboardCloneResult - actions handle data operations
245
246
  actionsResult,
247
+ createdFromTemplate: fromType === "template",
246
248
  });
247
249
  }
248
250
  // ═══════════════════════════════════════════════════════════════════════════
@@ -264,6 +266,7 @@ export async function handleCreate(args, client, getTemplateId) {
264
266
  workflowError,
265
267
  sourcePersonaType,
266
268
  dashboardCloneResult,
269
+ createdFromTemplate: fromType === "template",
267
270
  });
268
271
  }
269
272
  return buildCreateResult({
@@ -274,6 +277,7 @@ export async function handleCreate(args, client, getTemplateId) {
274
277
  workflowError,
275
278
  sourcePersonaType,
276
279
  dashboardCloneResult,
280
+ createdFromTemplate: fromType === "template",
277
281
  });
278
282
  }
279
283
  /**
@@ -299,6 +303,18 @@ function buildCreateResult(opts) {
299
303
  if (opts.sourcePersonaType) {
300
304
  result.source_persona_type = opts.sourcePersonaType;
301
305
  }
306
+ // ── CRITICAL GUIDANCE: Template workflows are minimal starters ──
307
+ // This is where LLMs often go wrong - they create from template and think they're done
308
+ if (opts.createdFromTemplate && !opts.workflowApplied) {
309
+ result._warning = "PERSONA CREATED BUT WORKFLOW IS INCOMPLETE. Template workflows are minimal starters (just trigger→respond).";
310
+ result._required_next_steps = [
311
+ "1. BUILD WORKFLOW: Add intent categorization, search nodes, response handling",
312
+ `2. If uploading docs: Workflow MUST have search/v2 node or documents will NOT be used`,
313
+ `3. Get current workflow: workflow(mode='get', persona_id='${opts.newPersonaId}')`,
314
+ `4. Deploy complete workflow: workflow(mode='deploy', persona_id='${opts.newPersonaId}', workflow_def={...})`,
315
+ ];
316
+ result._common_mistake = "Creating from template, uploading docs, and declaring 'done' WITHOUT building the workflow. The deploy will now BLOCK this pattern.";
317
+ }
302
318
  if (opts.dashboardCloneResult) {
303
319
  result.dashboard_data_clone = opts.dashboardCloneResult;
304
320
  }
@@ -2,6 +2,7 @@
2
2
  * Persona LIST mode handler
3
3
  *
4
4
  * Lists all personas with optional filtering.
5
+ * Fetches all pages from the API (no artificial limit).
5
6
  */
6
7
  import { getTemplates, normalizeTriggerType } from "../utils.js";
7
8
  /**
@@ -10,10 +11,12 @@ import { getTemplates, normalizeTriggerType } from "../utils.js";
10
11
  * @param args.query - Filter by name (partial match)
11
12
  * @param args.status - Filter by status
12
13
  * @param args.trigger_type - Filter by trigger type
13
- * @param args.limit - Max results (default 50)
14
+ * @param args.limit - Max results (optional, no default limit)
14
15
  */
15
16
  export async function handleList(args, client) {
17
+ // Fetch all personas (client handles pagination)
16
18
  let personas = await client.getPersonasForTenant();
19
+ const totalBeforeFilter = personas.length;
17
20
  // Apply filters
18
21
  if (args.query) {
19
22
  const q = args.query.toLowerCase();
@@ -25,14 +28,16 @@ export async function handleList(args, client) {
25
28
  if (args.trigger_type) {
26
29
  personas = personas.filter(p => p.trigger_type === args.trigger_type);
27
30
  }
28
- // Apply limit
29
- const limit = args.limit || 50;
30
- personas = personas.slice(0, limit);
31
+ // Apply limit only if explicitly provided
32
+ if (args.limit && typeof args.limit === 'number') {
33
+ personas = personas.slice(0, args.limit);
34
+ }
31
35
  // Get templates for type lookup (cached)
32
36
  const templates = await getTemplates(client);
33
37
  const templateMap = new Map(templates.map(t => [t.id, t]));
34
38
  return {
35
39
  count: personas.length,
40
+ total: totalBeforeFilter,
36
41
  personas: personas.map(p => {
37
42
  // Get type from template (dynamic, tenant-specific)
38
43
  const template = templateMap.get(p.template_id ?? "");
@@ -12,7 +12,9 @@
12
12
  * routes to handleWorkflow BEFORE reaching this handler. See handlePersona()
13
13
  * in handlers-consolidated.ts.
14
14
  */
15
+ import { PROJECT_TYPES } from "../../../sdk/knowledge.js";
15
16
  import { resolvePersona, validateWidgetsForApi } from "../utils.js";
17
+ import { validateSearchDataSourceConsistency, validationToHandlerResult } from "../workflow/validation.js";
16
18
  /**
17
19
  * Apply WorkflowSpec changes to an existing workflow_def.
18
20
  * This preserves action-specific inputs that compileWorkflow would lose.
@@ -200,7 +202,7 @@ export async function handleUpdate(args, client) {
200
202
  : undefined;
201
203
  // Determine persona type from projectSettings.projectType
202
204
  const projectSettings = existingProtoConfig.projectSettings;
203
- const isVoice = projectSettings?.projectType === 5;
205
+ const isVoice = projectSettings?.projectType === PROJECT_TYPES.voice;
204
206
  // Build merged proto_config with smart widget-level merging
205
207
  // Accept both 'config' (tool schema) and 'proto_config' (internal) for compatibility
206
208
  let mergedProtoConfig = { ...existingProtoConfig };
@@ -310,6 +312,19 @@ export async function handleUpdate(args, client) {
310
312
  // Use raw workflow_def if provided, otherwise keep existing
311
313
  workflowToSet = args.workflow ?? existingWorkflow;
312
314
  }
315
+ // ── VALIDATION: Use shared validation rules ──
316
+ // Run for any workflow update (workflow_spec or raw workflow)
317
+ if (workflowToSet) {
318
+ const validation = await validateSearchDataSourceConsistency(workflowToSet, persona.id, client);
319
+ if (!validation.valid) {
320
+ return validationToHandlerResult(validation, {
321
+ preview: preview,
322
+ persona_id: persona.id,
323
+ persona_name: persona.name,
324
+ changes: transformResult.changes || [],
325
+ });
326
+ }
327
+ }
313
328
  // ── Preview mode - return without deploying ──
314
329
  if (preview && workflowSpec) {
315
330
  return {
@@ -317,6 +332,7 @@ export async function handleUpdate(args, client) {
317
332
  persona_id: persona.id,
318
333
  persona_name: persona.name,
319
334
  persona_type: isVoice ? "voice" : "chat/dashboard",
335
+ validation_passed: true,
320
336
  changes: transformResult.changes || [],
321
337
  modified_workflow: workflowToSet,
322
338
  hint: "Set preview=false to deploy these changes",
@@ -333,7 +349,13 @@ export async function handleUpdate(args, client) {
333
349
  });
334
350
  }
335
351
  catch (apiErr) {
336
- throw apiErr;
352
+ // Return structured error consistent with other handlers
353
+ const errorMessage = apiErr instanceof Error ? apiErr.message : String(apiErr);
354
+ return {
355
+ error: `Update failed: ${errorMessage}`,
356
+ persona_id: persona.id,
357
+ persona_name: persona.name,
358
+ };
337
359
  }
338
360
  return {
339
361
  success: true,
@@ -3,9 +3,18 @@
3
3
  *
4
4
  * Deploys a workflow_def to an existing persona.
5
5
  * Includes deprecation warnings (does not block, just warns).
6
+ *
7
+ * BLOCKING VALIDATION:
8
+ * - Uses shared validation from validation.ts
6
9
  */
10
+ import { PROJECT_TYPES } from "../../../sdk/knowledge.js";
7
11
  import { sanitizeWorkflowForDeploy } from "./utils.js";
8
12
  import { DEPRECATED_ACTIONS_FALLBACK, checkWorkflowDeprecations } from "./index.js";
13
+ import { validateSearchDataSourceConsistency, validationToHandlerResult,
14
+ // Re-export for backwards compatibility
15
+ hasKnowledgeSearchNodes, extractActionTypeName, } from "./validation.js";
16
+ // Re-export for backwards compatibility with existing imports
17
+ export { hasKnowledgeSearchNodes, extractActionTypeName };
9
18
  /**
10
19
  * Handle workflow deploy mode
11
20
  */
@@ -23,8 +32,17 @@ export async function handleWorkflowDeploy(args, client) {
23
32
  if (!persona) {
24
33
  return { error: `Persona not found: ${personaId}` };
25
34
  }
26
- // Sanitize workflow before deployment
35
+ // Sanitize workflow FIRST so we validate what we'll actually deploy
27
36
  const sanitizedWorkflow = sanitizeWorkflowForDeploy(workflowDef);
37
+ // ── BLOCKING VALIDATION: Use shared validation rules ──
38
+ const validation = await validateSearchDataSourceConsistency(sanitizedWorkflow, personaId, client);
39
+ if (!validation.valid) {
40
+ return validationToHandlerResult(validation, {
41
+ mode: "deploy",
42
+ persona_id: personaId,
43
+ persona_name: persona.name,
44
+ });
45
+ }
28
46
  // Check for deprecated actions (warn, don't block)
29
47
  const deprecationWarnings = checkWorkflowDeprecations(workflowDef, DEPRECATED_ACTIONS_FALLBACK);
30
48
  // Determine proto_config to use: provided > existing
@@ -33,7 +51,7 @@ export async function handleWorkflowDeploy(args, client) {
33
51
  const protoConfigToUse = providedProtoConfig || existingProtoConfig || {};
34
52
  // Check if this is a voice persona and warn if critical settings are missing
35
53
  const projectSettings = protoConfigToUse.projectSettings;
36
- const isVoice = projectSettings?.projectType === 5;
54
+ const isVoice = projectSettings?.projectType === PROJECT_TYPES.voice;
37
55
  const warnings = [];
38
56
  // Add deprecation warning message if deprecated actions found
39
57
  if (deprecationWarnings.length > 0) {
@@ -15,6 +15,7 @@ import { runIntentArchitect } from "../../../sdk/intent-architect.js";
15
15
  import { ensureActionRegistry } from "../../../sdk/action-registry.js";
16
16
  import { ensureSchemaRegistry, validateWorkflowSpec, generateActionCatalogForLLM } from "../../../sdk/workflow-validator.js";
17
17
  import { getTemplates, normalizeTriggerType } from "../../handlers/index.js";
18
+ import { validateSearchDataSourceConsistency, validationToHandlerResult } from "./validation.js";
18
19
  /**
19
20
  * Get persona widget context for workflow bindings
20
21
  */
@@ -141,7 +142,20 @@ async function deployToNewPersona(args, client, compiled, actionRegistry, getTem
141
142
  ...existingProtoConfig,
142
143
  widgets: Array.from(widgetMap.values()),
143
144
  };
144
- // Step 4: Deploy workflow
145
+ // Step 4: Validate and Deploy workflow
146
+ // Validate search/data source consistency before deployment
147
+ const validation = await validateSearchDataSourceConsistency(compiled.workflow_def, newPersonaId, client);
148
+ if (!validation.valid) {
149
+ // Return validation error but keep persona (user can fix and retry)
150
+ return {
151
+ success: false,
152
+ personaId: newPersonaId,
153
+ personaName,
154
+ validationError: validation.error,
155
+ _fix: validation._fix,
156
+ workflowAttempted: compiled.workflow_def,
157
+ };
158
+ }
145
159
  // NOTE: The SDK's updateAiEmployee() handles workflowName namespace automatically.
146
160
  // It will copy from existing workflow if present, or generate a valid namespace if not.
147
161
  // It also fixes the results format. No need to manually manipulate these here.
@@ -293,6 +307,16 @@ export async function handleWorkflowGenerate(args, client, getTemplateId) {
293
307
  if (!persona) {
294
308
  return { error: `Persona not found: ${personaId}` };
295
309
  }
310
+ // Validate search/data source consistency before deployment
311
+ const validation = await validateSearchDataSourceConsistency(compiled.workflow_def, personaId, client);
312
+ if (!validation.valid) {
313
+ return validationToHandlerResult(validation, {
314
+ mode: "generate",
315
+ persona_id: personaId,
316
+ persona_name: persona.name,
317
+ workflow_preview: compiled.workflow_def,
318
+ });
319
+ }
296
320
  await client.updateAiEmployee({
297
321
  persona_id: personaId,
298
322
  workflow: compiled.workflow_def,
@@ -303,7 +327,20 @@ export async function handleWorkflowGenerate(args, client, getTemplateId) {
303
327
  else if (!preview && !personaId) {
304
328
  // Greenfield: create new persona
305
329
  const deployResult = await deployToNewPersona(args, client, compiled, actionRegistry, getTemplateId);
306
- if (deployResult.workflowDeployError) {
330
+ if (deployResult.validationError) {
331
+ // Validation failed - persona created but workflow not deployed
332
+ return {
333
+ error: deployResult.validationError,
334
+ _fix: deployResult._fix,
335
+ persona_id: deployResult.personaId,
336
+ persona_name: deployResult.personaName,
337
+ persona_created: true,
338
+ workflow_deployed: false,
339
+ workflow_attempted: deployResult.workflowAttempted,
340
+ hint: "Persona created, but workflow validation failed. Fix the issue and retry deployment.",
341
+ };
342
+ }
343
+ else if (deployResult.workflowDeployError) {
307
344
  result.workflow_deploy_error = deployResult.workflowDeployError;
308
345
  result.workflow_attempted = deployResult.workflowAttempted;
309
346
  result.status = "partial";
@@ -26,6 +26,7 @@ import { handleWorkflowOptimize } from "./optimize.js";
26
26
  import { handleWorkflowDeploy } from "./deploy.js";
27
27
  import { handleWorkflowGenerate } from "./generate.js";
28
28
  import { handleWorkflowModify } from "./modify.js";
29
+ import { handleWorkflowValidate } from "./validate.js";
29
30
  // Re-export for internal use (persona handler routes to these)
30
31
  export { handleWorkflowAnalyze } from "./analyze.js";
31
32
  export { handleWorkflowCompare } from "./compare.js";
@@ -240,6 +241,9 @@ export async function handleWorkflow(args, client, getTemplateId) {
240
241
  if (mode === "deploy") {
241
242
  return handleWorkflowDeploy(args, client);
242
243
  }
244
+ if (mode === "validate") {
245
+ return handleWorkflowValidate(args, client);
246
+ }
243
247
  // INTERNAL MODES (called from persona tool, not workflow tool)
244
248
  if (mode === "modify" || mode === "extend") {
245
249
  return handleWorkflowModify(args, client);
@@ -278,15 +282,15 @@ export async function handleWorkflow(args, client, getTemplateId) {
278
282
  // Invalid mode
279
283
  return {
280
284
  error: `Invalid or missing mode: ${mode}`,
281
- public_modes: ["get", "deploy"],
282
- hint: "MCP provides data (get) and executes (deploy). LLM does all thinking.",
285
+ public_modes: ["get", "deploy", "validate"],
286
+ hint: "MCP provides data (get), validates (validate), and executes (deploy). LLM does all thinking.",
283
287
  };
284
288
  }
285
289
  /**
286
290
  * Check if a workflow mode has been extracted
287
291
  */
288
292
  export function hasExtractedWorkflowHandler(mode) {
289
- const extractedModes = ["get", "deploy", "modify", "extend", "generate", "analyze", "optimize", "compare"];
293
+ const extractedModes = ["get", "deploy", "validate", "modify", "extend", "generate", "analyze", "optimize", "compare"];
290
294
  return extractedModes.includes(mode);
291
295
  }
292
296
  /**
@@ -296,6 +300,7 @@ export function getWorkflowModeHandler(mode) {
296
300
  switch (mode) {
297
301
  case "get":
298
302
  case "deploy":
303
+ case "validate":
299
304
  case "modify":
300
305
  case "extend":
301
306
  case "generate":
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { sanitizeWorkflowForDeploy } from "./utils.js";
12
12
  import { ensureSchemaRegistry } from "../../../sdk/workflow-validator.js";
13
+ import { validateSearchDataSourceConsistency, validationToHandlerResult } from "./validation.js";
13
14
  // ─────────────────────────────────────────────────────────────────────────
14
15
  // Action Building Helpers (use ActionRegistry for metadata)
15
16
  // ─────────────────────────────────────────────────────────────────────────
@@ -311,6 +312,8 @@ export async function handleWorkflowModify(args, client) {
311
312
  const operations = args.operations;
312
313
  const preview = Boolean(args.preview);
313
314
  // If no structured operations provided, return guidance
315
+ // NOTE: This handler does NOT parse natural language - Agent must build operations
316
+ const userInput = args.input;
314
317
  if (!operations || operations.length === 0) {
315
318
  // Fetch persona for context
316
319
  const persona = await client.getPersonaById(personaId);
@@ -328,9 +331,16 @@ export async function handleWorkflowModify(args, client) {
328
331
  ? schemaRegistry.getAllActions().map(a => ({ name: a.name, category: a.category, description: a.description }))
329
332
  : [];
330
333
  return {
331
- message: "No operations provided. Returning workflow context for Agent to build operations.",
334
+ message: userInput
335
+ ? `You provided input="${userInput}". This handler requires STRUCTURED operations, not natural language. Build operations from the context below.`
336
+ : "No operations provided. Returning workflow context for Agent to build operations.",
332
337
  persona_id: personaId,
333
338
  persona_name: persona.name,
339
+ // If user provided input, remind them to convert to operations
340
+ ...(userInput && {
341
+ _warning: "IMPORTANT: The 'input' parameter is for providing context to the Agent, not for direct execution. The Agent must convert this into structured 'operations'.",
342
+ user_intent: userInput,
343
+ }),
334
344
  // Current workflow state (DATA for Agent)
335
345
  current_nodes: actions.map(a => ({
336
346
  name: a.name,
@@ -341,10 +351,11 @@ export async function handleWorkflowModify(args, client) {
341
351
  available_actions: availableActions.slice(0, 20), // Top 20 for context
342
352
  // Guidance for Agent
343
353
  _next_steps: [
344
- "1. Review current_nodes and available_actions above",
345
- "2. Fetch ema://rules/anti-patterns to check for issues",
346
- "3. Build ModificationOperation[] based on your analysis",
347
- "4. Pass operations as 'operations' parameter",
354
+ userInput
355
+ ? `1. Interpret the user intent: "${userInput}"`
356
+ : "1. Review current_nodes and available_actions above",
357
+ "2. Build ModificationOperation[] based on your analysis",
358
+ "3. Call again with operations=[{type:'insert'|'remove'|'rewire', ...}]",
348
359
  ],
349
360
  // Example operation structures
350
361
  example_operations: {
@@ -387,13 +398,25 @@ export async function handleWorkflowModify(args, client) {
387
398
  const schemaRegistry = await ensureSchemaRegistry(client);
388
399
  // Apply the structured operations
389
400
  const result = applyWorkflowModifications(workflow, operations, schemaRegistry);
390
- // Sanitize before deploy
401
+ // Sanitize before validation (validate what we'll actually deploy)
391
402
  const sanitized = sanitizeWorkflowForDeploy(result.workflow);
403
+ // ── VALIDATION: Use shared validation rules ──
404
+ // Run BEFORE preview so user knows about issues early
405
+ const validation = await validateSearchDataSourceConsistency(sanitized, personaId, client);
406
+ if (!validation.valid) {
407
+ return validationToHandlerResult(validation, {
408
+ preview: preview,
409
+ persona_id: personaId,
410
+ persona_name: persona.name,
411
+ changes_attempted: result.changesApplied,
412
+ });
413
+ }
392
414
  if (preview) {
393
415
  return {
394
416
  preview: true,
395
417
  persona_id: personaId,
396
418
  persona_name: persona.name,
419
+ validation_passed: true, // Explicitly confirm validation passed
397
420
  // What would change
398
421
  changes_applied: result.changesApplied,
399
422
  nodes_added: result.nodesAdded,
@@ -402,14 +425,18 @@ export async function handleWorkflowModify(args, client) {
402
425
  connections_changed: result.connectionsChanged,
403
426
  // The modified workflow (LLM should analyze with ema://rules/*)
404
427
  modified_workflow: sanitized,
405
- _tip: "Preview complete. Use ema://rules/anti-patterns to check for issues. Call without preview=true to deploy.",
428
+ _tip: "Preview complete. Validation passed. Call without preview=true to deploy.",
406
429
  };
407
430
  }
408
431
  // Deploy the workflow
432
+ // CRITICAL: Must include proto_config alongside workflow for changes to persist
433
+ // Always use the FULL existing proto_config from the persona
434
+ const existingProtoConfig = (persona.proto_config ?? {});
409
435
  try {
410
436
  await client.updateAiEmployee({
411
437
  persona_id: personaId,
412
438
  workflow: sanitized,
439
+ proto_config: existingProtoConfig,
413
440
  });
414
441
  return {
415
442
  success: true,
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Workflow Validation Handler
3
+ *
4
+ * Implements static validation using path enumeration.
5
+ * Validates: required outputs, multiple writers, response/abstain, category hierarchy.
6
+ */
7
+ import { validateWorkflowStatic } from "../../../sdk/workflow-static-validator.js";
8
+ /**
9
+ * Handle workflow(mode="validate") - static validation
10
+ *
11
+ * Validates workflow using path enumeration:
12
+ * - Enumerates all execution paths
13
+ * - Checks each path for named results compliance
14
+ * - Returns enhanced error messages (what, why, how)
15
+ */
16
+ export async function handleWorkflowValidate(args, client) {
17
+ const personaId = args.persona_id;
18
+ const workflowDef = args.workflow_def;
19
+ const workflowSpec = args.workflow_spec;
20
+ const validationType = args.validation_type || "all";
21
+ // Get workflow to validate
22
+ let spec = null;
23
+ if (workflowSpec) {
24
+ // Use provided workflow spec
25
+ spec = workflowSpec;
26
+ }
27
+ else if (workflowDef) {
28
+ // Convert workflow_def to WorkflowSpec
29
+ // TODO: Implement conversion
30
+ return {
31
+ error: "workflow_def validation not yet implemented. Use workflow_spec instead.",
32
+ };
33
+ }
34
+ else if (personaId) {
35
+ // Get workflow from persona
36
+ const persona = await client.getPersonaById(personaId);
37
+ if (!persona) {
38
+ return { error: `Persona not found: ${personaId}` };
39
+ }
40
+ const personaWorkflowDef = persona.workflow_def;
41
+ if (!personaWorkflowDef) {
42
+ return {
43
+ error: "Persona has no workflow to validate",
44
+ _tip: "Deploy a workflow first, then validate it",
45
+ };
46
+ }
47
+ // TODO: Convert workflow_def to WorkflowSpec
48
+ return {
49
+ error: "workflow_def validation not yet implemented. Use workflow_spec instead.",
50
+ _tip: "Convert workflow_def to WorkflowSpec format for validation",
51
+ };
52
+ }
53
+ else {
54
+ return {
55
+ error: "Either persona_id, workflow_def, or workflow_spec required",
56
+ };
57
+ }
58
+ if (!spec) {
59
+ return { error: "Could not extract workflow spec for validation" };
60
+ }
61
+ // Perform static validation
62
+ const validationResult = validateWorkflowStatic(spec, {
63
+ max_paths: args.max_paths || 1000,
64
+ timeout_ms: args.timeout_ms || 100,
65
+ });
66
+ // Format response
67
+ return {
68
+ valid: validationResult.valid,
69
+ errors: validationResult.errors,
70
+ warnings: validationResult.warnings,
71
+ info: validationResult.info,
72
+ summary: validationResult.summary,
73
+ _tip: validationResult.valid
74
+ ? "Workflow passed static validation. Ready to deploy."
75
+ : "Fix errors before deploying. Use error messages (what/why/how) to guide fixes.",
76
+ _next_step: validationResult.valid
77
+ ? "Deploy with: workflow(mode='deploy', persona_id='...', workflow_def={...})"
78
+ : [
79
+ "Review errors above",
80
+ "Fix issues using 'how_to_fix' guidance",
81
+ "Re-validate: workflow(mode='validate', ...)",
82
+ "Deploy when valid: workflow(mode='deploy', ...)",
83
+ ],
84
+ };
85
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Workflow Validation Rules
3
+ *
4
+ * Shared validation logic for all workflow deployment paths.
5
+ * Called by: deploy.ts, modify.ts, update.ts (persona)
6
+ */
7
+ /**
8
+ * Extract action type name from various workflow_def formats.
9
+ * Handles both string and object formats:
10
+ * - String: "search/v2"
11
+ * - Object: { name: { name: "search", namespaces: [...] }, version: "v2" }
12
+ */
13
+ export function extractActionTypeName(action) {
14
+ if (typeof action === "string") {
15
+ return action;
16
+ }
17
+ if (typeof action === "object" && action !== null) {
18
+ const actionObj = action;
19
+ // Format: { name: { name: "search", namespaces: [...] } }
20
+ if (actionObj.name && typeof actionObj.name === "object") {
21
+ const nameObj = actionObj.name;
22
+ if (typeof nameObj.name === "string") {
23
+ return nameObj.name;
24
+ }
25
+ }
26
+ // Format: { name: "search" } (direct)
27
+ if (typeof actionObj.name === "string") {
28
+ return actionObj.name;
29
+ }
30
+ }
31
+ return "";
32
+ }
33
+ /**
34
+ * Detect knowledge search nodes in a workflow.
35
+ * Returns true if workflow contains search/v2 or search_datastore nodes (NOT web_search).
36
+ */
37
+ export function hasKnowledgeSearchNodes(workflowDef) {
38
+ const actions = (workflowDef.actions ?? []);
39
+ return actions.some(a => {
40
+ // Extract action type from various formats
41
+ const actionTypeName = extractActionTypeName(a.action) || a.actionType || "";
42
+ const nodeName = (typeof a.name === "string" ? a.name : "").toLowerCase();
43
+ // Match: search, search/v2, search_datastore
44
+ // Exclude: web_search, live_web_search
45
+ const isSearchNode = actionTypeName.includes("search") ||
46
+ nodeName.includes("search");
47
+ const isWebSearch = actionTypeName.includes("web_search") ||
48
+ nodeName.includes("web_search");
49
+ return isSearchNode && !isWebSearch;
50
+ });
51
+ }
52
+ /**
53
+ * Validate workflow and data source consistency.
54
+ *
55
+ * Rules:
56
+ * 1. If workflow has search nodes → data sources MUST be indexed
57
+ * 2. If persona has indexed data sources → workflow MUST have search nodes
58
+ *
59
+ * @param workflowDef - The workflow to validate
60
+ * @param personaId - The persona ID for data source lookup
61
+ * @param client - EmaClient for API calls
62
+ * @returns Validation result with error details if invalid
63
+ */
64
+ export async function validateSearchDataSourceConsistency(workflowDef, personaId, client) {
65
+ const hasSearchNodes = hasKnowledgeSearchNodes(workflowDef);
66
+ // Try to get data source stats
67
+ let dataSourceStats = null;
68
+ try {
69
+ dataSourceStats = await client.getDataSourceAggregates(personaId);
70
+ }
71
+ catch {
72
+ // If we can't check stats, log warning but don't block
73
+ console.error(`[validation] Could not verify data sources for ${personaId}`);
74
+ }
75
+ // If we couldn't get stats, allow through (don't block on API errors)
76
+ if (!dataSourceStats) {
77
+ return { valid: true };
78
+ }
79
+ // RULE 1: If workflow has search nodes, data sources MUST be indexed
80
+ if (hasSearchNodes) {
81
+ if (dataSourceStats.total === 0) {
82
+ return {
83
+ valid: false,
84
+ error: "DEPLOYMENT BLOCKED: Workflow contains knowledge search nodes but persona has no data sources attached.",
85
+ validation_failed: "search_nodes_require_data_sources",
86
+ current_state: {
87
+ search_nodes_detected: true,
88
+ data_sources_total: 0,
89
+ data_sources_indexed: 0,
90
+ },
91
+ _fix: [
92
+ "1. Upload documents: persona(id='...',data={method:'upload',path:'/path/to/doc.pdf'})",
93
+ "2. Check status: persona(id='...',data={method:'stats'}) → wait for 'success' > 0",
94
+ "3. Then retry the deployment",
95
+ ],
96
+ };
97
+ }
98
+ if (dataSourceStats.success === 0) {
99
+ return {
100
+ valid: false,
101
+ error: "DEPLOYMENT BLOCKED: Workflow contains knowledge search nodes but no data sources have been successfully indexed.",
102
+ validation_failed: "search_nodes_require_indexed_data",
103
+ current_state: {
104
+ search_nodes_detected: true,
105
+ data_sources_total: dataSourceStats.total,
106
+ data_sources_pending: dataSourceStats.pending,
107
+ data_sources_indexed: dataSourceStats.success,
108
+ data_sources_failed: dataSourceStats.failed,
109
+ },
110
+ _fix: dataSourceStats.pending > 0
111
+ ? [
112
+ "Data sources are still indexing. Wait for indexing to complete:",
113
+ "1. Check status: persona(id='...',data={method:'stats'})",
114
+ "2. Retry when 'success' > 0",
115
+ ]
116
+ : [
117
+ "All data sources failed indexing. Check file formats and re-upload:",
118
+ "1. List files: persona(id='...',data={method:'list'})",
119
+ "2. Delete failed: persona(id='...',data={method:'delete',file_id:'...'})",
120
+ "3. Re-upload: persona(id='...',data={method:'upload',path:'...'})",
121
+ ],
122
+ };
123
+ }
124
+ }
125
+ // RULE 2: If persona has indexed data sources, workflow MUST have search nodes
126
+ if (!hasSearchNodes && dataSourceStats.success > 0) {
127
+ return {
128
+ valid: false,
129
+ error: "DEPLOYMENT BLOCKED: Persona has indexed knowledge files but workflow has no search nodes. The uploaded documents will NEVER be used.",
130
+ validation_failed: "data_sources_require_search_nodes",
131
+ current_state: {
132
+ search_nodes_detected: false,
133
+ data_sources_total: dataSourceStats.total,
134
+ data_sources_indexed: dataSourceStats.success,
135
+ },
136
+ _fix: [
137
+ "Your workflow needs a search node to query the uploaded documents.",
138
+ "1. Get workflow schema: workflow(mode='get', persona_id='...')",
139
+ "2. Add search node: Update workflow_def to include a search/v2 node",
140
+ "3. Example pattern: trigger → summarizer → search/v2 → respond → output",
141
+ ],
142
+ };
143
+ }
144
+ return { valid: true };
145
+ }
146
+ /**
147
+ * Convert validation result to HandlerResult format for returning errors.
148
+ */
149
+ export function validationToHandlerResult(validation, additionalContext) {
150
+ if (validation.valid) {
151
+ throw new Error("validationToHandlerResult called on valid result");
152
+ }
153
+ return {
154
+ error: validation.error,
155
+ validation_failed: validation.validation_failed,
156
+ current_state: validation.current_state,
157
+ _fix: validation._fix,
158
+ ...additionalContext,
159
+ };
160
+ }