Skip to main content

nika_cli/
workflow.rs

1//! Workflow management subcommand handler
2
3use clap::Subcommand;
4use std::path::PathBuf;
5
6use colored::Colorize;
7
8use nika_engine::ast::{parse_analyzed, parse_workflow};
9use nika_engine::error::NikaError;
10
11/// Workflow management actions
12#[derive(Subcommand)]
13pub enum WorkflowAction {
14    /// Open workflow in interactive editor
15    Edit {
16        /// Path to .nika.yaml file
17        file: PathBuf,
18    },
19
20    /// Add a new task interactively
21    AddTask {
22        /// Path to .nika.yaml file
23        file: PathBuf,
24
25        /// Task ID (generated if not provided)
26        #[arg(long)]
27        id: Option<String>,
28
29        /// Task verb (infer, exec, fetch, invoke, agent)
30        #[arg(long, value_name = "VERB")]
31        verb: Option<String>,
32
33        /// Insert after this task ID
34        #[arg(long)]
35        after: Option<String>,
36    },
37
38    /// Visualize workflow as DAG graph
39    Graph {
40        /// Path to .nika.yaml file
41        file: PathBuf,
42
43        /// Output format: ascii, dot, mermaid
44        #[arg(short, long, default_value = "ascii")]
45        format: String,
46
47        /// Output file (stdout if not specified)
48        #[arg(short, long)]
49        output: Option<PathBuf>,
50    },
51
52    /// Validate workflow with suggestions for improvements
53    Check {
54        /// Path to .nika.yaml file
55        file: PathBuf,
56
57        /// Show improvement suggestions
58        #[arg(long)]
59        suggest: bool,
60
61        /// Output format: text, json
62        #[arg(long, default_value = "text")]
63        format: String,
64    },
65}
66
67pub async fn handle_workflow_command(action: WorkflowAction, quiet: bool) -> Result<(), NikaError> {
68    match action {
69        WorkflowAction::Edit { file } => {
70            // TUI Studio editor lives in the nika binary crate, not nika-cli.
71            // When called from `nika`, main.rs overrides this via the tui module.
72            let _ = (file, quiet);
73            Err(NikaError::ConfigError {
74                reason: "TUI feature not enabled. Rebuild with `--features tui`".to_string(),
75            })
76        }
77
78        WorkflowAction::AddTask {
79            file,
80            id,
81            verb,
82            after,
83        } => {
84            // Validate file exists
85            if !file.exists() {
86                return Err(NikaError::WorkflowNotFound {
87                    path: file.to_string_lossy().to_string(),
88                });
89            }
90
91            // Read existing workflow
92            let content = std::fs::read_to_string(&file)?;
93
94            // Generate task ID if not provided
95            let task_id = id.unwrap_or_else(|| {
96                format!("task_{}", chrono::Utc::now().timestamp_millis() % 10000)
97            });
98
99            // Default verb is infer
100            let task_verb = verb.unwrap_or_else(|| "infer".to_string());
101
102            // Build the new task YAML
103            let new_task = match task_verb.as_str() {
104                "infer" => format!(
105                    r#"  - id: {task_id}
106    infer: "TODO: Add your prompt here"
107"#
108                ),
109                "exec" => format!(
110                    r#"  - id: {task_id}
111    exec: "echo 'TODO: Add your command here'"
112"#
113                ),
114                "fetch" => format!(
115                    r#"  - id: {task_id}
116    fetch:
117      url: "https://example.com/api"
118      method: GET
119"#
120                ),
121                "invoke" => format!(
122                    r#"  - id: {task_id}
123    invoke:
124      mcp: novanet
125      tool: novanet_context
126      params: {{}}
127"#
128                ),
129                "agent" => format!(
130                    r#"  - id: {task_id}
131    agent:
132      prompt: "TODO: Add your agent prompt here"
133      max_turns: 5
134"#
135                ),
136                _ => {
137                    return Err(NikaError::ValidationError {
138                        reason: format!(
139                            "Unknown verb '{task_verb}'. Valid: infer, exec, fetch, invoke, agent"
140                        ),
141                    });
142                }
143            };
144
145            // Find insertion point
146            let mut lines: Vec<&str> = content.lines().collect();
147            let mut insert_index = None;
148
149            // Find tasks: section and optionally the task to insert after
150            let mut in_tasks = false;
151            let mut after_task_end = None;
152
153            for (i, line) in lines.iter().enumerate() {
154                if line.trim() == "tasks:" {
155                    in_tasks = true;
156                    continue;
157                }
158                if in_tasks {
159                    // Check if this is a task start (- id:)
160                    if line.trim().starts_with("- id:") {
161                        // Check if this is the task we want to insert after
162                        if let Some(ref after_id) = after {
163                            if line.contains(after_id) {
164                                // Mark that we found the after task
165                                after_task_end = Some(i);
166                            } else if after_task_end.is_some() {
167                                // We've found the next task after our target
168                                insert_index = Some(i);
169                                break;
170                            }
171                        }
172                    }
173                    // Check for top-level sections (context:, mcp:, etc.)
174                    if !line.starts_with(' ') && !line.starts_with('-') && line.contains(':') {
175                        insert_index = Some(i);
176                        break;
177                    }
178                }
179            }
180
181            // If no insert point found, append at end of tasks
182            let insert_at = insert_index.unwrap_or(lines.len());
183
184            // Insert the new task
185            let new_task_lines: Vec<&str> = new_task.lines().collect();
186            for (j, task_line) in new_task_lines.iter().enumerate() {
187                lines.insert(insert_at + j, task_line);
188            }
189
190            // Write back
191            let new_content = lines.join("\n");
192            std::fs::write(&file, new_content)?;
193
194            if !quiet {
195                println!(
196                    "{} Added task '{}' ({}) to {}",
197                    "✓".green(),
198                    task_id.cyan(),
199                    task_verb.yellow(),
200                    file.display()
201                );
202                if let Some(after_id) = after {
203                    println!("  {} Inserted after task '{}'", "→".cyan(), after_id);
204                }
205            }
206
207            Ok(())
208        }
209
210        WorkflowAction::Graph {
211            file,
212            format,
213            output,
214        } => {
215            // Validate file exists
216            if !file.exists() {
217                return Err(NikaError::WorkflowNotFound {
218                    path: file.to_string_lossy().to_string(),
219                });
220            }
221
222            // Parse workflow
223            let content = std::fs::read_to_string(&file)?;
224            let workflow = parse_workflow(&content)?;
225
226            // Generate graph based on format
227            let graph_output = match format.as_str() {
228                "ascii" => generate_ascii_dag(&workflow),
229                "dot" => generate_dot_dag(&workflow),
230                "mermaid" => generate_mermaid_dag(&workflow),
231                _ => {
232                    return Err(NikaError::ValidationError {
233                        reason: format!("Unknown format '{format}'. Valid: ascii, dot, mermaid"),
234                    });
235                }
236            };
237
238            // Output
239            match output {
240                Some(path) => {
241                    std::fs::write(&path, &graph_output)?;
242                    if !quiet {
243                        println!(
244                            "{} DAG written to {} (format: {})",
245                            "✓".green(),
246                            path.display(),
247                            format.cyan()
248                        );
249                    }
250                }
251                None => {
252                    println!("{graph_output}");
253                }
254            }
255
256            Ok(())
257        }
258
259        WorkflowAction::Check {
260            file,
261            suggest,
262            format,
263        } => {
264            // Validate file exists
265            if !file.exists() {
266                if format == "json" {
267                    let error_json = serde_json::json!({
268                        "valid": false,
269                        "file": file.to_string_lossy(),
270                        "error": format!("Workflow not found: {}", file.display()),
271                    });
272                    println!("{}", serde_json::to_string_pretty(&error_json)?);
273                }
274                return Err(NikaError::WorkflowNotFound {
275                    path: file.to_string_lossy().to_string(),
276                });
277            }
278
279            // Parse and validate — catch parse errors for JSON output
280            let content = std::fs::read_to_string(&file)?;
281            let workflow = match parse_workflow(&content) {
282                Ok(w) => w,
283                Err(e) => {
284                    if format == "json" {
285                        let error_json = serde_json::json!({
286                            "valid": false,
287                            "file": file.to_string_lossy(),
288                            "error": e.to_string(),
289                        });
290                        println!("{}", serde_json::to_string_pretty(&error_json)?);
291                    }
292                    return Err(e);
293                }
294            };
295
296            // Collect validation results
297            let mut issues: Vec<(String, String, String)> = Vec::new(); // (level, code, message)
298            let mut suggestions: Vec<String> = Vec::new();
299
300            // Check schema version
301            let schema = workflow.schema.clone();
302            if !schema.starts_with("nika/workflow@") {
303                issues.push((
304                    "error".to_string(),
305                    "NIKA-001".to_string(),
306                    "Missing or invalid schema version".to_string(),
307                ));
308            } else if let Some(version) = schema.strip_prefix("nika/workflow@") {
309                if version != "0.12" && suggest {
310                    suggestions.push(format!(
311                        "Consider upgrading from @{version} to @0.12 for latest features"
312                    ));
313                }
314            }
315
316            // Check for common issues
317            if workflow.tasks.is_empty() {
318                issues.push((
319                    "error".to_string(),
320                    "NIKA-010".to_string(),
321                    "Workflow has no tasks".to_string(),
322                ));
323            }
324
325            // Check for duplicate task IDs
326            let mut seen_ids = std::collections::HashSet::new();
327            for task in &workflow.tasks {
328                if !seen_ids.insert(&task.id) {
329                    issues.push((
330                        "error".to_string(),
331                        "NIKA-141".to_string(),
332                        format!("Duplicate task ID: '{}'", task.id),
333                    ));
334                }
335            }
336
337            // Check provider API keys (BUG 6 / NIKA-032)
338            // Parse the analyzed workflow to access per-task providers
339            {
340                let mut providers_used: std::collections::HashSet<String> =
341                    std::collections::HashSet::new();
342
343                // Workflow default provider
344                providers_used.insert(workflow.provider.clone());
345
346                // Per-task providers from analyzed AST
347                if let Ok(analyzed) = parse_analyzed(&content) {
348                    for task in &analyzed.tasks {
349                        if let Some(ref p) = task.provider {
350                            providers_used.insert(p.clone());
351                        }
352                    }
353                }
354
355                // Check each provider for its env var
356                for provider_name in &providers_used {
357                    if let Some(provider) = nika_engine::core::find_provider(provider_name) {
358                        if provider.requires_key && !provider.has_env_key() {
359                            issues.push((
360                                "warn".to_string(),
361                                "NIKA-032".to_string(),
362                                format!(
363                                    "{} not set (provider '{}' used in workflow)",
364                                    provider.env_var, provider_name
365                                ),
366                            ));
367                        }
368                    }
369                }
370            }
371
372            // Check for unused tasks (not referenced in deps or with blocks)
373            if suggest {
374                let mut referenced: std::collections::HashSet<&str> =
375                    std::collections::HashSet::new();
376                for (source, target) in workflow.edges() {
377                    referenced.insert(source);
378                    referenced.insert(target);
379                }
380                for task in &workflow.tasks {
381                    if let Some(ref with_spec) = task.with_spec {
382                        for entry in with_spec.values() {
383                            if let Some(task_ref) = entry.task_id() {
384                                referenced.insert(task_ref);
385                            }
386                        }
387                    }
388                }
389                for task in &workflow.tasks {
390                    if !referenced.contains(task.id.as_str()) && workflow.tasks.len() > 1 {
391                        // First task or leaf tasks are often not referenced
392                        if workflow.tasks.first().map(|t| &t.id) != Some(&task.id) {
393                            suggestions.push(format!(
394                                "Task '{}' is not referenced by any other task",
395                                task.id
396                            ));
397                        }
398                    }
399                }
400            }
401
402            // Output results
403            let has_errors = issues.iter().any(|(level, _, _)| level == "error");
404            match format.as_str() {
405                "json" => {
406                    let result = serde_json::json!({
407                        "file": file.to_string_lossy(),
408                        "valid": !has_errors,
409                        "issues": issues.iter().map(|(level, code, msg)| {
410                            serde_json::json!({
411                                "level": level,
412                                "code": code,
413                                "message": msg
414                            })
415                        }).collect::<Vec<_>>(),
416                        "suggestions": suggestions
417                    });
418                    println!("{}", serde_json::to_string_pretty(&result)?);
419
420                    // JSON mode must also exit non-zero on validation errors
421                    if has_errors {
422                        let error_count = issues
423                            .iter()
424                            .filter(|(level, _, _)| level == "error")
425                            .count();
426                        return Err(NikaError::ValidationError {
427                            reason: format!("{} validation error(s) found", error_count),
428                        });
429                    }
430                }
431                _ => {
432                    // Text format
433                    if issues.is_empty() {
434                        if !quiet {
435                            println!("{} {} is valid", "✓".green(), file.display());
436                        }
437                    } else {
438                        for (level, code, msg) in &issues {
439                            let prefix = if level == "error" {
440                                "✗".red()
441                            } else {
442                                "⚠".yellow()
443                            };
444                            println!("{} [{}] {}", prefix, code.cyan(), msg);
445                        }
446                    }
447
448                    if suggest && !suggestions.is_empty() {
449                        println!();
450                        println!("{}", "Suggestions:".cyan().bold());
451                        for suggestion in &suggestions {
452                            println!("  {} {}", "→".cyan(), suggestion);
453                        }
454                    }
455
456                    if has_errors {
457                        let error_count = issues
458                            .iter()
459                            .filter(|(level, _, _)| level == "error")
460                            .count();
461                        return Err(NikaError::ValidationError {
462                            reason: format!("{} validation error(s) found", error_count),
463                        });
464                    }
465                }
466            }
467
468            Ok(())
469        }
470    }
471}
472
473fn generate_ascii_dag(workflow: &nika_engine::ast::Workflow) -> String {
474    let mut output = String::new();
475    let name = "(unnamed)";
476    output.push_str("┌─────────────────────────────────────────┐\n");
477    output.push_str(&format!("│ DAG: {name}"));
478    let padding = 40usize.saturating_sub(name.len() + 6);
479    output.push_str(&" ".repeat(padding));
480    output.push_str("│\n");
481    output.push_str("├─────────────────────────────────────────┤\n");
482
483    // Build task list with verb icons
484    for task in &workflow.tasks {
485        let verb_icon = match &task.action {
486            nika_engine::ast::TaskAction::Infer { .. } => "⚡",
487            nika_engine::ast::TaskAction::Exec { .. } => "📟",
488            nika_engine::ast::TaskAction::Fetch { .. } => "🛰️",
489            nika_engine::ast::TaskAction::Invoke { .. } => "🔌",
490            nika_engine::ast::TaskAction::Agent { .. } => "🐔",
491        };
492        let line = format!("│ {} {}", verb_icon, task.id);
493        let line_padding = 40usize.saturating_sub(task.id.len() + 4);
494        output.push_str(&format!("{}{}│\n", line, " ".repeat(line_padding)));
495    }
496
497    // Show flows (derived from task dependencies)
498    let edges = workflow.edges();
499    if !edges.is_empty() {
500        output.push_str("├─────────────────────────────────────────┤\n");
501        output.push_str("│ Edges:                                  │\n");
502        for (source, target) in &edges {
503            let flow_str = format!("  {source} → {target}");
504            let flow_padding = 39usize.saturating_sub(flow_str.len());
505            output.push_str(&format!("│{}{}│\n", flow_str, " ".repeat(flow_padding)));
506        }
507    }
508
509    output.push_str("└─────────────────────────────────────────┘\n");
510    output
511}
512
513/// Generate DOT (Graphviz) DAG representation
514fn generate_dot_dag(workflow: &nika_engine::ast::Workflow) -> String {
515    let mut output = String::new();
516    let name = "workflow";
517    output.push_str(&format!("digraph {name} {{\n"));
518    output.push_str("  rankdir=LR;\n");
519    output.push_str("  node [shape=box, style=rounded];\n\n");
520
521    // Add nodes with styling based on verb
522    for task in &workflow.tasks {
523        let color = match &task.action {
524            nika_engine::ast::TaskAction::Infer { .. } => "lightblue",
525            nika_engine::ast::TaskAction::Exec { .. } => "lightgreen",
526            nika_engine::ast::TaskAction::Fetch { .. } => "lightyellow",
527            nika_engine::ast::TaskAction::Invoke { .. } => "lightpink",
528            nika_engine::ast::TaskAction::Agent { .. } => "plum",
529        };
530        output.push_str(&format!(
531            "  {} [label=\"{}\", fillcolor={}, style=\"rounded,filled\"];\n",
532            task.id.replace('-', "_"),
533            task.id,
534            color
535        ));
536    }
537
538    // Add edges (derived from task dependencies)
539    output.push('\n');
540    for (source, target) in workflow.edges() {
541        output.push_str(&format!(
542            "  {} -> {};\n",
543            source.replace('-', "_"),
544            target.replace('-', "_")
545        ));
546    }
547
548    output.push_str("}\n");
549    output
550}
551
552/// Generate Mermaid DAG representation
553fn generate_mermaid_dag(workflow: &nika_engine::ast::Workflow) -> String {
554    let mut output = String::new();
555    output.push_str("```mermaid\ngraph LR\n");
556
557    // Add nodes with styling
558    for task in &workflow.tasks {
559        let shape = match &task.action {
560            nika_engine::ast::TaskAction::Infer { .. } => ("([", "])"), // Stadium
561            nika_engine::ast::TaskAction::Exec { .. } => ("[", "]"),    // Rectangle
562            nika_engine::ast::TaskAction::Fetch { .. } => ("{{", "}}"), // Hexagon
563            nika_engine::ast::TaskAction::Invoke { .. } => ("[[", "]]"), // Subroutine
564            nika_engine::ast::TaskAction::Agent { .. } => ("((", "))"), // Circle
565        };
566        let verb = match &task.action {
567            nika_engine::ast::TaskAction::Infer { .. } => "infer",
568            nika_engine::ast::TaskAction::Exec { .. } => "exec",
569            nika_engine::ast::TaskAction::Fetch { .. } => "fetch",
570            nika_engine::ast::TaskAction::Invoke { .. } => "invoke",
571            nika_engine::ast::TaskAction::Agent { .. } => "agent",
572        };
573        output.push_str(&format!(
574            "  {}{}{} : {}{}\n",
575            task.id.replace('-', "_"),
576            shape.0,
577            task.id,
578            verb,
579            shape.1
580        ));
581    }
582
583    // Add edges (derived from task dependencies)
584    output.push('\n');
585    for (source, target) in workflow.edges() {
586        output.push_str(&format!(
587            "  {} --> {}\n",
588            source.replace('-', "_"),
589            target.replace('-', "_")
590        ));
591    }
592
593    output.push_str("```\n");
594    output
595}