@minded-ai/mindedjs 3.0.4-beta.3 → 3.0.6-beta.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.
@@ -0,0 +1,502 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as yaml from 'js-yaml';
4
+ import { CronExpressionParser } from 'cron-parser';
5
+
6
+ interface ValidationResult {
7
+ success: boolean;
8
+ flowName?: string;
9
+ stats?: {
10
+ nodes: number;
11
+ edges: number;
12
+ nodeTypes: string[];
13
+ edgeTypes: string[];
14
+ };
15
+ errors?: string[];
16
+ warnings?: string[];
17
+ message: string;
18
+ }
19
+
20
+ /**
21
+ * Validates a cron expression format using cron-parser.
22
+ * Cron format: minute hour day month day-of-week (5 fields)
23
+ */
24
+ export function validateCronExpression(cronExpression: string): { valid: boolean; error?: string } {
25
+ if (typeof cronExpression !== 'string' || cronExpression.trim() === '') {
26
+ return { valid: false, error: 'Cron expression must be a non-empty string' };
27
+ }
28
+
29
+ try {
30
+ CronExpressionParser.parse(cronExpression.trim());
31
+ return { valid: true };
32
+ } catch (error) {
33
+ return {
34
+ valid: false,
35
+ error: error instanceof Error ? error.message : 'Invalid cron expression format',
36
+ };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Validates an IANA timezone identifier.
42
+ * Uses Intl.DateTimeFormat to check if the timezone is valid.
43
+ */
44
+ export function validateTimezone(timezone: string): { valid: boolean; error?: string } {
45
+ if (typeof timezone !== 'string' || timezone.trim() === '') {
46
+ return { valid: false, error: 'Timezone must be a non-empty string' };
47
+ }
48
+
49
+ try {
50
+ // Try to create a date formatter with the timezone
51
+ const formatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone });
52
+ // Test if the timezone is valid by trying to format a date
53
+ formatter.format(new Date());
54
+ return { valid: true };
55
+ } catch {
56
+ return {
57
+ valid: false,
58
+ error: `Invalid IANA timezone identifier: "${timezone}"`,
59
+ };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Validates a schedule trigger node configuration.
65
+ * Returns an array of error messages (empty if valid).
66
+ */
67
+ export function validateScheduleTrigger(
68
+ node: any,
69
+ nodeIndex: string
70
+ ): { errors: string[]; warnings: string[] } {
71
+ const errors: string[] = [];
72
+ const warnings: string[] = [];
73
+
74
+ if (!node.cronExpression) {
75
+ errors.push(`${nodeIndex} "${node.name}": schedule trigger missing required field "cronExpression"`);
76
+ } else if (typeof node.cronExpression !== 'string') {
77
+ errors.push(`${nodeIndex} "${node.name}": schedule trigger "cronExpression" must be a string`);
78
+ } else {
79
+ const cronValidation = validateCronExpression(node.cronExpression);
80
+ if (!cronValidation.valid) {
81
+ errors.push(`${nodeIndex} "${node.name}": invalid cron expression - ${cronValidation.error}`);
82
+ }
83
+ }
84
+
85
+ if (node.timezone !== undefined) {
86
+ if (typeof node.timezone !== 'string') {
87
+ errors.push(`${nodeIndex} "${node.name}": schedule trigger "timezone" must be a string`);
88
+ } else {
89
+ const timezoneValidation = validateTimezone(node.timezone);
90
+ if (!timezoneValidation.valid) {
91
+ errors.push(`${nodeIndex} "${node.name}": ${timezoneValidation.error}`);
92
+ }
93
+ }
94
+ }
95
+
96
+ return { errors, warnings };
97
+ }
98
+
99
+ /**
100
+ * Validates a MindedJS YAML flow file.
101
+ */
102
+ export function validateFlow(flowPath: string): ValidationResult {
103
+ // Resolve the path
104
+ const resolvedPath = path.isAbsolute(flowPath) ? flowPath : path.join(process.cwd(), flowPath);
105
+
106
+ // Read the file
107
+ let flowContent: string;
108
+ try {
109
+ flowContent = fs.readFileSync(resolvedPath, 'utf-8');
110
+ } catch (error) {
111
+ return {
112
+ success: false,
113
+ message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
114
+ };
115
+ }
116
+
117
+ // Parse YAML
118
+ let flow: any;
119
+ try {
120
+ flow = yaml.load(flowContent);
121
+ } catch (error) {
122
+ return {
123
+ success: false,
124
+ message: `Invalid YAML syntax: ${error instanceof Error ? error.message : String(error)}`,
125
+ };
126
+ }
127
+
128
+ // Validate flow structure
129
+ const errors: string[] = [];
130
+ const warnings: string[] = [];
131
+
132
+ // Check for required top-level fields
133
+ if (!flow.name) {
134
+ errors.push('Flow is missing required field: "name"');
135
+ }
136
+
137
+ if (!flow.nodes || !Array.isArray(flow.nodes)) {
138
+ errors.push('Flow is missing required field: "nodes" (must be an array)');
139
+ }
140
+
141
+ if (!flow.edges || !Array.isArray(flow.edges)) {
142
+ errors.push('Flow is missing required field: "edges" (must be an array)');
143
+ }
144
+
145
+ // If basic structure is invalid, return early
146
+ if (errors.length > 0) {
147
+ return {
148
+ success: false,
149
+ errors,
150
+ warnings,
151
+ message: `Flow validation failed with ${errors.length} error(s)`,
152
+ };
153
+ }
154
+
155
+ // Create a set of node names for validation
156
+ const nodeNames = new Set<string>();
157
+ const nodesByName = new Map<string, any>();
158
+
159
+ // Validate nodes
160
+ for (let i = 0; i < flow.nodes.length; i++) {
161
+ const node = flow.nodes[i];
162
+ const nodeIndex = `Node ${i + 1}`;
163
+
164
+ // Check for required name field
165
+ if (!node.name) {
166
+ errors.push(`${nodeIndex}: Missing required field "name"`);
167
+ continue;
168
+ }
169
+
170
+ // Check for duplicate node names
171
+ if (nodeNames.has(node.name)) {
172
+ errors.push(`${nodeIndex} "${node.name}": Duplicate node name`);
173
+ }
174
+ nodeNames.add(node.name);
175
+ nodesByName.set(node.name, node);
176
+
177
+ // Check for required type field
178
+ if (!node.type) {
179
+ errors.push(`${nodeIndex} "${node.name}": Missing required field "type"`);
180
+ continue;
181
+ }
182
+
183
+ // Validate node based on type
184
+ switch (node.type) {
185
+ case 'trigger':
186
+ if (!node.triggerType) {
187
+ errors.push(`${nodeIndex} "${node.name}": trigger node missing required field "triggerType"`);
188
+ } else if (!['manual', 'app', 'webhook', 'schedule'].includes(node.triggerType)) {
189
+ errors.push(
190
+ `${nodeIndex} "${node.name}": invalid triggerType "${node.triggerType}". Must be one of: manual, app, webhook, schedule`
191
+ );
192
+ }
193
+ if (node.triggerType === 'app' && !node.appTriggerId) {
194
+ errors.push(`${nodeIndex} "${node.name}": app trigger missing required field "appTriggerId"`);
195
+ }
196
+ if (node.triggerType === 'schedule') {
197
+ const scheduleValidation = validateScheduleTrigger(node, nodeIndex);
198
+ errors.push(...scheduleValidation.errors);
199
+ warnings.push(...scheduleValidation.warnings);
200
+ }
201
+ break;
202
+
203
+ case 'promptNode':
204
+ if (!node.prompt) {
205
+ errors.push(`${nodeIndex} "${node.name}": promptNode missing required field "prompt"`);
206
+ }
207
+ if (!node.displayName || node.displayName.trim() === '') {
208
+ errors.push(`${nodeIndex} "${node.name}": promptNode missing required non-empty field "displayName"`);
209
+ }
210
+ if (node.llmConfig) {
211
+ if (!node.llmConfig.name) {
212
+ errors.push(`${nodeIndex} "${node.name}": llmConfig missing required field "name"`);
213
+ }
214
+ if (!node.llmConfig.properties) {
215
+ warnings.push(`${nodeIndex} "${node.name}": llmConfig missing "properties" field`);
216
+ }
217
+ }
218
+ break;
219
+
220
+ case 'thinking':
221
+ if (!node.prompt) {
222
+ errors.push(`${nodeIndex} "${node.name}": thinking node missing required field "prompt"`);
223
+ }
224
+ if (node.humanInTheLoop !== undefined) {
225
+ errors.push(
226
+ `${nodeIndex} "${node.name}": thinking node does not support "humanInTheLoop" property (only prompt nodes support this)`
227
+ );
228
+ }
229
+ if (node.canStayOnNode !== undefined) {
230
+ errors.push(
231
+ `${nodeIndex} "${node.name}": thinking node does not support "canStayOnNode" property (only prompt nodes support this)`
232
+ );
233
+ }
234
+ if (node.llmConfig) {
235
+ if (!node.llmConfig.name) {
236
+ errors.push(`${nodeIndex} "${node.name}": llmConfig missing required field "name"`);
237
+ }
238
+ if (!node.llmConfig.properties) {
239
+ warnings.push(`${nodeIndex} "${node.name}": llmConfig missing "properties" field`);
240
+ }
241
+ }
242
+ break;
243
+
244
+ case 'tool':
245
+ if (!node.toolName) {
246
+ errors.push(`${nodeIndex} "${node.name}": tool node missing required field "toolName"`);
247
+ }
248
+ break;
249
+
250
+ case 'junction':
251
+ // Junction only requires name and type, which we already checked
252
+ break;
253
+
254
+ case 'jumpToNode':
255
+ if (!node.targetNodeId) {
256
+ errors.push(`${nodeIndex} "${node.name}": jumpToNode missing required field "targetNodeId"`);
257
+ }
258
+ break;
259
+
260
+ case 'browserTask':
261
+ if (!node.prompt) {
262
+ errors.push(`${nodeIndex} "${node.name}": browserTask node missing required field "prompt"`);
263
+ }
264
+ break;
265
+
266
+ default:
267
+ warnings.push(`${nodeIndex} "${node.name}": unknown node type "${node.type}"`);
268
+ }
269
+ }
270
+
271
+ // Create a map to track which nodes have edges
272
+ const nodesWithEdges = new Set<string>();
273
+
274
+ // Validate edges
275
+ for (let i = 0; i < flow.edges.length; i++) {
276
+ const edge = flow.edges[i];
277
+ const edgeIndex = `Edge ${i + 1}`;
278
+
279
+ // Check for required fields
280
+ if (!edge.source) {
281
+ errors.push(`${edgeIndex}: Missing required field "source"`);
282
+ continue;
283
+ }
284
+
285
+ if (!edge.target) {
286
+ errors.push(`${edgeIndex}: Missing required field "target"`);
287
+ continue;
288
+ }
289
+
290
+ if (!edge.type) {
291
+ errors.push(`${edgeIndex}: Missing required field "type"`);
292
+ continue;
293
+ }
294
+
295
+ // Track nodes that have edges
296
+ nodesWithEdges.add(edge.source);
297
+ nodesWithEdges.add(edge.target);
298
+
299
+ // Check if source and target nodes exist
300
+ if (!nodeNames.has(edge.source)) {
301
+ errors.push(`${edgeIndex}: source node "${edge.source}" does not exist`);
302
+ }
303
+
304
+ if (!nodeNames.has(edge.target)) {
305
+ errors.push(`${edgeIndex}: target node "${edge.target}" does not exist`);
306
+ }
307
+
308
+ // Validate edge based on type
309
+ switch (edge.type) {
310
+ case 'stepForward':
311
+ // stepForward only requires source, target, and type
312
+ break;
313
+
314
+ case 'promptCondition':
315
+ if (!edge.prompt) {
316
+ errors.push(`${edgeIndex}: promptCondition edge missing required field "prompt"`);
317
+ }
318
+ break;
319
+
320
+ case 'logicalCondition':
321
+ if (!edge.condition) {
322
+ errors.push(`${edgeIndex}: logicalCondition edge missing required field "condition"`);
323
+ }
324
+ break;
325
+
326
+ default:
327
+ warnings.push(`${edgeIndex}: unknown edge type "${edge.type}"`);
328
+ }
329
+ }
330
+
331
+ // Check for orphaned nodes (nodes without any edges)
332
+ // Note: Trigger nodes can be entry points without incoming edges
333
+ for (const nodeName of nodeNames) {
334
+ const node = nodesByName.get(nodeName);
335
+ if (!nodesWithEdges.has(nodeName) && node.type !== 'trigger') {
336
+ warnings.push(`Node "${nodeName}": orphaned node (not connected to any edges)`);
337
+ }
338
+ }
339
+
340
+ // Check for trigger nodes - at least one trigger should exist
341
+ const triggerNodes = flow.nodes.filter((n: any) => n.type === 'trigger');
342
+ if (triggerNodes.length === 0) {
343
+ warnings.push('Flow has no trigger nodes (entry points)');
344
+ }
345
+
346
+ const success = errors.length === 0;
347
+
348
+ return {
349
+ success,
350
+ flowName: flow.name,
351
+ stats: {
352
+ nodes: flow.nodes.length,
353
+ edges: flow.edges.length,
354
+ nodeTypes: [...new Set(flow.nodes.map((n: any) => n.type))] as string[],
355
+ edgeTypes: [...new Set(flow.edges.map((e: any) => e.type))] as string[],
356
+ },
357
+ errors: errors.length > 0 ? errors : undefined,
358
+ warnings: warnings.length > 0 ? warnings : undefined,
359
+ message:
360
+ errors.length === 0
361
+ ? warnings.length === 0
362
+ ? 'Flow is valid with no issues'
363
+ : `Flow is valid but has ${warnings.length} warning(s)`
364
+ : `Flow validation failed with ${errors.length} error(s)`,
365
+ };
366
+ }
367
+
368
+ interface MindedConfig {
369
+ flows?: string[];
370
+ tools?: string[];
371
+ playbooks?: string[];
372
+ agent?: string;
373
+ }
374
+
375
+ /**
376
+ * Finds all YAML files in a directory recursively.
377
+ */
378
+ function findYamlFiles(dir: string): string[] {
379
+ const files: string[] = [];
380
+
381
+ if (!fs.existsSync(dir)) {
382
+ return files;
383
+ }
384
+
385
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
386
+
387
+ for (const entry of entries) {
388
+ const fullPath = path.join(dir, entry.name);
389
+ if (entry.isDirectory()) {
390
+ files.push(...findYamlFiles(fullPath));
391
+ } else if (entry.isFile() && (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml'))) {
392
+ files.push(fullPath);
393
+ }
394
+ }
395
+
396
+ return files;
397
+ }
398
+
399
+ /**
400
+ * CLI command handler for validate
401
+ */
402
+ export function runValidateCommand(): void {
403
+ const mindedConfigPath = path.join(process.cwd(), 'minded.json');
404
+
405
+ // Check if minded.json exists
406
+ if (!fs.existsSync(mindedConfigPath)) {
407
+ console.error('Error: minded.json not found in the current directory');
408
+ console.error('Make sure you are running this command from a MindedJS agent project root');
409
+ process.exit(1);
410
+ }
411
+
412
+ // Read and parse minded.json
413
+ let mindedConfig: MindedConfig;
414
+ try {
415
+ const configContent = fs.readFileSync(mindedConfigPath, 'utf8');
416
+ mindedConfig = JSON.parse(configContent);
417
+ } catch (error) {
418
+ console.error(`Error: Failed to read or parse minded.json: ${error instanceof Error ? error.message : String(error)}`);
419
+ process.exit(1);
420
+ }
421
+
422
+ // Get flows folders
423
+ const flowsFolders = mindedConfig.flows;
424
+ if (!flowsFolders || flowsFolders.length === 0) {
425
+ console.error('Error: No flows folders configured in minded.json');
426
+ process.exit(1);
427
+ }
428
+
429
+ // Find all YAML files in flow folders
430
+ const flowFiles: string[] = [];
431
+ for (const folder of flowsFolders) {
432
+ const resolvedFolder = path.isAbsolute(folder) ? folder : path.join(process.cwd(), folder);
433
+ flowFiles.push(...findYamlFiles(resolvedFolder));
434
+ }
435
+
436
+ if (flowFiles.length === 0) {
437
+ console.error('Error: No flow YAML files found in configured flows folders');
438
+ process.exit(1);
439
+ }
440
+
441
+ console.log(`\nValidating ${flowFiles.length} flow(s)...\n`);
442
+
443
+ // Validate each flow
444
+ const results: { path: string; result: ValidationResult }[] = [];
445
+ let totalErrors = 0;
446
+ let totalWarnings = 0;
447
+
448
+ for (const flowFile of flowFiles) {
449
+ const result = validateFlow(flowFile);
450
+ results.push({ path: flowFile, result });
451
+ totalErrors += result.errors?.length ?? 0;
452
+ totalWarnings += result.warnings?.length ?? 0;
453
+ }
454
+
455
+ // Print results for each flow
456
+ for (const { path: flowPath, result } of results) {
457
+ const relativePath = path.relative(process.cwd(), flowPath);
458
+ const statusIcon = result.success ? '✅' : '❌';
459
+
460
+ console.log(`${statusIcon} ${relativePath}`);
461
+
462
+ if (result.flowName) {
463
+ console.log(` Flow: ${result.flowName}`);
464
+ }
465
+
466
+ if (result.stats) {
467
+ console.log(` Stats: ${result.stats.nodes} nodes, ${result.stats.edges} edges`);
468
+ }
469
+
470
+ if (result.errors && result.errors.length > 0) {
471
+ console.log(' Errors:');
472
+ result.errors.forEach((error) => {
473
+ console.log(` ❌ ${error}`);
474
+ });
475
+ }
476
+
477
+ if (result.warnings && result.warnings.length > 0) {
478
+ console.log(' Warnings:');
479
+ result.warnings.forEach((warning) => {
480
+ console.log(` ⚠️ ${warning}`);
481
+ });
482
+ }
483
+
484
+ console.log('');
485
+ }
486
+
487
+ // Print summary
488
+ const allPassed = totalErrors === 0;
489
+ console.log('─'.repeat(50));
490
+
491
+ if (allPassed) {
492
+ console.log(`\n✅ All ${flowFiles.length} flow(s) validated successfully!`);
493
+ } else {
494
+ console.log(`\n❌ Validation failed!`);
495
+ }
496
+
497
+ console.log(` Total: ${flowFiles.length} flow(s), ${totalErrors} error(s), ${totalWarnings} warning(s)\n`);
498
+
499
+ if (!allPassed) {
500
+ process.exit(1);
501
+ }
502
+ }
@@ -11,7 +11,8 @@ import { Environment } from '../types/Platform.types';
11
11
  export type Playbook = {
12
12
  id: string;
13
13
  name: string;
14
- blocks: OutputBlockData[];
14
+ blocks?: OutputBlockData[];
15
+ prompt?: string;
15
16
  };
16
17
 
17
18
  /**
@@ -147,7 +148,7 @@ function loadPlaybooksFromDirectories(directories: string[]): Playbook[] {
147
148
  const fileContent = fs.readFileSync(file, 'utf8');
148
149
  const playbook = yaml.load(fileContent) as Playbook;
149
150
 
150
- if (playbook && playbook.name && playbook.blocks) {
151
+ if (playbook && playbook.name && (playbook.blocks || playbook.prompt)) {
151
152
  playbooks.push(playbook);
152
153
  logger.info({ msg: `Loaded playbook: ${playbook.name} from ${file}` });
153
154
  } else {
@@ -191,7 +192,13 @@ export function combinePlaybooks(playbooks: Playbook[]): string | null {
191
192
 
192
193
  // Combine all playbooks into sections
193
194
  const sections = playbooks.map((playbook) => {
194
- const markdownContent = editorJsToMarkdown(playbook.blocks);
195
+ let markdownContent = playbook.prompt;
196
+ if (!markdownContent && playbook.blocks) {
197
+ markdownContent = editorJsToMarkdown(playbook.blocks);
198
+ }
199
+ if (!markdownContent) {
200
+ throw new Error('Playbook must have either a prompt or blocks');
201
+ }
195
202
  return `# ${playbook.name}\n${markdownContent}`;
196
203
  });
197
204