Skip to main content

axon/
flow_inspect.rs

1//! Flow Inspector — runtime introspection for deployed AXON flows.
2//!
3//! Given a flow name and its stored source, re-compiles (lex → parse → IR)
4//! and extracts structured metadata:
5//!   - Flow signature (name, parameters, return type)
6//!   - Steps with persona refs, tools used, probes, weaves
7//!   - Data edges (step dependencies)
8//!   - Execution levels (parallelism structure)
9//!   - Anchors defined in the source
10//!   - Tools declared in the source
11//!   - Personas referenced
12//!   - Source hash and line count
13//!
14//! Used by:
15//!   - `GET /v1/inspect/:name` — API endpoint for flow introspection
16
17use serde::Serialize;
18
19// ── Inspection result ───────────────────────────────────────────────────
20
21/// Complete inspection report for a deployed flow.
22#[derive(Debug, Clone, Serialize)]
23pub struct FlowInspection {
24    /// Flow name.
25    pub name: String,
26    /// Source file name.
27    pub source_file: String,
28    /// Source hash (from version registry).
29    pub source_hash: String,
30    /// Number of lines in the source.
31    pub source_lines: usize,
32    /// Flow signature details.
33    pub signature: FlowSignature,
34    /// Steps in the flow.
35    pub steps: Vec<StepInfo>,
36    /// Data edges between steps.
37    pub edges: Vec<EdgeInfo>,
38    /// Execution levels (parallelism structure).
39    pub execution_levels: Vec<Vec<String>>,
40    /// Anchors defined in the source.
41    pub anchors: Vec<AnchorInfo>,
42    /// Tools declared in the source.
43    pub tools: Vec<ToolInfo>,
44    /// Unique personas referenced across all steps.
45    pub personas_referenced: Vec<String>,
46    /// Compilation metadata.
47    pub compilation: CompilationInfo,
48}
49
50/// Flow signature — name, parameters, return type.
51#[derive(Debug, Clone, Serialize)]
52pub struct FlowSignature {
53    pub name: String,
54    pub parameters: Vec<ParameterInfo>,
55    pub return_type: String,
56    pub return_type_optional: bool,
57}
58
59/// A flow parameter.
60#[derive(Debug, Clone, Serialize)]
61pub struct ParameterInfo {
62    pub name: String,
63    pub type_name: String,
64}
65
66/// Information about a single step in a flow.
67#[derive(Debug, Clone, Serialize)]
68pub struct StepInfo {
69    pub name: String,
70    pub persona_ref: String,
71    pub has_tool_use: bool,
72    pub has_probe: bool,
73    pub has_reason: bool,
74    pub has_weave: bool,
75    pub output_type: String,
76    pub source_line: u32,
77}
78
79/// A data edge between steps.
80#[derive(Debug, Clone, Serialize)]
81pub struct EdgeInfo {
82    pub from: String,
83    pub to: String,
84    pub type_name: String,
85}
86
87/// Anchor information.
88#[derive(Debug, Clone, Serialize)]
89pub struct AnchorInfo {
90    pub name: String,
91    pub description: String,
92    pub enforce: String,
93    pub on_violation: String,
94    pub source_line: u32,
95}
96
97/// Tool declaration information.
98#[derive(Debug, Clone, Serialize)]
99pub struct ToolInfo {
100    pub name: String,
101    pub provider: String,
102    pub timeout: String,
103    pub sandbox: Option<bool>,
104    pub source_line: u32,
105}
106
107/// Compilation metadata.
108#[derive(Debug, Clone, Serialize)]
109pub struct CompilationInfo {
110    pub success: bool,
111    pub token_count: usize,
112    pub flow_count: usize,
113    pub anchor_count: usize,
114    pub tool_count: usize,
115    pub type_errors: Vec<String>,
116}
117
118// ── Inspector ───────────────────────────────────────────────────────────
119
120/// Inspect a flow by re-compiling its source.
121///
122/// Returns `Ok(inspection)` if the flow is found in the IR, or
123/// `Err(message)` if compilation fails or flow not found.
124pub fn inspect_flow(
125    flow_name: &str,
126    source: &str,
127    source_file: &str,
128    source_hash: &str,
129) -> Result<FlowInspection, String> {
130    // Lex
131    let tokens = crate::lexer::Lexer::new(source, source_file)
132        .tokenize()
133        .map_err(|e| format!("lex error: {e:?}"))?;
134
135    let token_count = tokens.len();
136
137    // Parse
138    let mut parser = crate::parser::Parser::new(tokens);
139    let program = parser
140        .parse()
141        .map_err(|e| format!("parse error: {e:?}"))?;
142
143    // Type check (non-fatal — report but continue)
144    let type_errors = crate::type_checker::TypeChecker::new(&program).check();
145    let type_error_msgs: Vec<String> = type_errors.iter().map(|e| format!("{e:?}")).collect();
146
147    // IR generation
148    let ir = crate::ir_generator::IRGenerator::new().generate(&program);
149
150    // Find the target flow
151    let ir_flow = ir
152        .flows
153        .iter()
154        .find(|f| f.name == flow_name)
155        .ok_or_else(|| format!("flow '{}' not found in IR (available: {})",
156            flow_name,
157            ir.flows.iter().map(|f| f.name.as_str()).collect::<Vec<_>>().join(", ")))?;
158
159    // Extract signature
160    let signature = FlowSignature {
161        name: ir_flow.name.clone(),
162        parameters: ir_flow
163            .parameters
164            .iter()
165            .map(|p| ParameterInfo {
166                name: p.name.clone(),
167                type_name: p.type_name.clone(),
168            })
169            .collect(),
170        return_type: if ir_flow.return_type_generic.is_empty() {
171            ir_flow.return_type_name.clone()
172        } else {
173            format!("{}<{}>", ir_flow.return_type_name, ir_flow.return_type_generic)
174        },
175        return_type_optional: ir_flow.return_type_optional,
176    };
177
178    // Extract steps
179    let mut steps = Vec::new();
180    let mut personas_set = std::collections::BTreeSet::new();
181
182    for node in &ir_flow.steps {
183        if let crate::ir_nodes::IRFlowNode::Step(step) = node {
184            if !step.persona_ref.is_empty() {
185                personas_set.insert(step.persona_ref.clone());
186            }
187            steps.push(StepInfo {
188                name: step.name.clone(),
189                persona_ref: step.persona_ref.clone(),
190                has_tool_use: step.use_tool.is_some(),
191                has_probe: step.probe.is_some(),
192                has_reason: step.reason.is_some(),
193                has_weave: step.weave.is_some(),
194                output_type: step.output_type.clone(),
195                source_line: step.source_line,
196            });
197        }
198    }
199
200    // Extract edges
201    let edges: Vec<EdgeInfo> = ir_flow
202        .edges
203        .iter()
204        .map(|e| EdgeInfo {
205            from: e.source_step.clone(),
206            to: e.target_step.clone(),
207            type_name: e.type_name.clone(),
208        })
209        .collect();
210
211    // Anchors
212    let anchors: Vec<AnchorInfo> = ir
213        .anchors
214        .iter()
215        .map(|a| AnchorInfo {
216            name: a.name.clone(),
217            description: a.description.clone(),
218            enforce: a.enforce.clone(),
219            on_violation: a.on_violation.clone(),
220            source_line: a.source_line,
221        })
222        .collect();
223
224    // Tools
225    let tools: Vec<ToolInfo> = ir
226        .tools
227        .iter()
228        .map(|t| ToolInfo {
229            name: t.name.clone(),
230            provider: t.provider.clone(),
231            timeout: t.timeout.clone(),
232            sandbox: t.sandbox,
233            source_line: t.source_line,
234        })
235        .collect();
236
237    let source_lines = source.lines().count();
238
239    Ok(FlowInspection {
240        name: flow_name.to_string(),
241        source_file: source_file.to_string(),
242        source_hash: source_hash.to_string(),
243        source_lines,
244        signature,
245        steps,
246        edges,
247        execution_levels: ir_flow.execution_levels.clone(),
248        anchors,
249        tools,
250        personas_referenced: personas_set.into_iter().collect(),
251        compilation: CompilationInfo {
252            success: type_error_msgs.is_empty(),
253            token_count,
254            flow_count: ir.flows.len(),
255            anchor_count: ir.anchors.len(),
256            tool_count: ir.tools.len(),
257            type_errors: type_error_msgs,
258        },
259    })
260}
261
262/// Quick summary for listing all deployed flows.
263#[derive(Debug, Clone, Serialize)]
264pub struct FlowSummary {
265    pub name: String,
266    pub source_file: String,
267    pub source_hash: String,
268    pub step_count: usize,
269    pub has_anchors: bool,
270    pub has_tools: bool,
271}
272
273/// Inspect all flows in a source, returning summaries.
274pub fn inspect_all_flows(
275    source: &str,
276    source_file: &str,
277    source_hash: &str,
278) -> Result<Vec<FlowSummary>, String> {
279    let tokens = crate::lexer::Lexer::new(source, source_file)
280        .tokenize()
281        .map_err(|e| format!("lex error: {e:?}"))?;
282
283    let mut parser = crate::parser::Parser::new(tokens);
284    let program = parser.parse().map_err(|e| format!("parse error: {e:?}"))?;
285    let ir = crate::ir_generator::IRGenerator::new().generate(&program);
286
287    let has_anchors = !ir.anchors.is_empty();
288    let has_tools = !ir.tools.is_empty();
289
290    Ok(ir
291        .flows
292        .iter()
293        .map(|f| {
294            let step_count = f
295                .steps
296                .iter()
297                .filter(|n| matches!(n, crate::ir_nodes::IRFlowNode::Step(_)))
298                .count();
299
300            FlowSummary {
301                name: f.name.clone(),
302                source_file: source_file.to_string(),
303                source_hash: source_hash.to_string(),
304                step_count,
305                has_anchors,
306                has_tools,
307            }
308        })
309        .collect())
310}
311
312// ── Graph export ────────────────────────────────────────────────────────
313
314/// Supported graph output formats.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum GraphFormat {
317    Dot,
318    Mermaid,
319}
320
321impl GraphFormat {
322    /// Parse from string ("dot", "mermaid"). Defaults to Dot.
323    pub fn from_str(s: &str) -> Self {
324        match s.to_lowercase().as_str() {
325            "mermaid" => GraphFormat::Mermaid,
326            _ => GraphFormat::Dot,
327        }
328    }
329
330    pub fn content_type(&self) -> &'static str {
331        match self {
332            GraphFormat::Dot => "text/vnd.graphviz",
333            GraphFormat::Mermaid => "text/plain",
334        }
335    }
336}
337
338/// Graph export result.
339#[derive(Debug, Clone, Serialize)]
340pub struct GraphExport {
341    /// Flow name.
342    pub flow_name: String,
343    /// Output format ("dot" or "mermaid").
344    pub format: String,
345    /// The graph source text.
346    pub graph: String,
347    /// Number of nodes (steps) in the graph.
348    pub node_count: usize,
349    /// Number of edges in the graph.
350    pub edge_count: usize,
351    /// Number of parallel groups detected.
352    pub parallel_groups: usize,
353    /// Maximum dependency depth.
354    pub max_depth: usize,
355}
356
357/// Generate a graph export for a specific flow from its source.
358///
359/// Compiles source → IR → DependencyGraph → DOT/Mermaid.
360pub fn export_flow_graph(
361    flow_name: &str,
362    source: &str,
363    source_file: &str,
364    format: GraphFormat,
365) -> Result<GraphExport, String> {
366    // Compile to IR
367    let tokens = crate::lexer::Lexer::new(source, source_file)
368        .tokenize()
369        .map_err(|e| format!("lex error: {e:?}"))?;
370
371    let mut parser = crate::parser::Parser::new(tokens);
372    let program = parser.parse().map_err(|e| format!("parse error: {e:?}"))?;
373    let ir = crate::ir_generator::IRGenerator::new().generate(&program);
374
375    // Build graphs from IR
376    let graphs = crate::graph_export::graph_from_ir(&ir);
377
378    // Find the target flow's graph
379    let (name, graph) = graphs
380        .into_iter()
381        .find(|(n, _)| n == flow_name)
382        .ok_or_else(|| format!("flow '{}' not found in graph analysis", flow_name))?;
383
384    let node_count = graph.steps.len();
385    let edge_count: usize = graph.steps.iter().map(|s| s.depends_on.len()).sum();
386    let parallel_group_count = graph.parallel_groups.len();
387    let max_depth = graph.max_depth;
388
389    let (graph_text, format_str) = match format {
390        GraphFormat::Dot => (crate::graph_export::to_dot(&name, &graph), "dot"),
391        GraphFormat::Mermaid => (crate::graph_export::to_mermaid(&name, &graph), "mermaid"),
392    };
393
394    Ok(GraphExport {
395        flow_name: name,
396        format: format_str.to_string(),
397        graph: graph_text,
398        node_count,
399        edge_count,
400        parallel_groups: parallel_group_count,
401        max_depth,
402    })
403}
404
405// ── Tests ────────────────────────────────────────────────────────────────
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    const SAMPLE_SOURCE: &str = r#"
412persona Analyst {
413  tone: "analytical"
414  domain: ["data", "statistics"]
415}
416
417anchor NoHallucination {
418  description: "Prevent fabrication"
419  require: factual
420  enforce: strict
421  on_violation: retry
422}
423
424tool Calculator {
425  provider: builtin
426  timeout: 5s
427}
428
429flow Analyze(data: Text) -> Report {
430  step gather use Analyst {
431    given: data
432    ask: "Summarize the data"
433  }
434  step conclude use Analyst {
435    given: gather.output
436    ask: "Draw conclusions"
437  }
438}
439"#;
440
441    #[test]
442    fn inspect_flow_basic() {
443        let result = inspect_flow("Analyze", SAMPLE_SOURCE, "test.axon", "abc123");
444        assert!(result.is_ok());
445
446        let inspection = result.unwrap();
447        assert_eq!(inspection.name, "Analyze");
448        assert_eq!(inspection.source_file, "test.axon");
449        assert_eq!(inspection.source_hash, "abc123");
450        assert!(inspection.source_lines > 0);
451
452        // Signature
453        assert_eq!(inspection.signature.name, "Analyze");
454        assert_eq!(inspection.signature.parameters.len(), 1);
455        assert_eq!(inspection.signature.parameters[0].name, "data");
456        assert_eq!(inspection.signature.parameters[0].type_name, "Text");
457
458        // Steps
459        assert_eq!(inspection.steps.len(), 2);
460        assert_eq!(inspection.steps[0].name, "gather");
461        assert_eq!(inspection.steps[1].name, "conclude");
462
463        // Personas referenced
464        assert!(inspection.personas_referenced.contains(&"Analyst".to_string()));
465
466        // Anchors
467        assert_eq!(inspection.anchors.len(), 1);
468        assert_eq!(inspection.anchors[0].name, "NoHallucination");
469
470        // Tools
471        assert_eq!(inspection.tools.len(), 1);
472        assert_eq!(inspection.tools[0].name, "Calculator");
473
474        // Compilation (type errors are non-fatal for inspection)
475        assert_eq!(inspection.compilation.flow_count, 1);
476    }
477
478    #[test]
479    fn inspect_flow_not_found() {
480        let result = inspect_flow("NonExistent", SAMPLE_SOURCE, "test.axon", "abc");
481        assert!(result.is_err());
482        let err = result.unwrap_err();
483        assert!(err.contains("not found"));
484    }
485
486    #[test]
487    fn inspect_flow_invalid_source() {
488        let result = inspect_flow("X", "this is not valid axon {{{{", "bad.axon", "x");
489        assert!(result.is_err());
490    }
491
492    #[test]
493    fn inspect_all_flows_basic() {
494        let result = inspect_all_flows(SAMPLE_SOURCE, "test.axon", "hash123");
495        assert!(result.is_ok());
496
497        let summaries = result.unwrap();
498        assert_eq!(summaries.len(), 1);
499        assert_eq!(summaries[0].name, "Analyze");
500        assert_eq!(summaries[0].step_count, 2);
501        assert!(summaries[0].has_anchors);
502        assert!(summaries[0].has_tools);
503    }
504
505    #[test]
506    fn flow_inspection_serializable() {
507        let result = inspect_flow("Analyze", SAMPLE_SOURCE, "test.axon", "abc123").unwrap();
508        let json = serde_json::to_value(&result).unwrap();
509        assert_eq!(json["name"], "Analyze");
510        assert!(json["signature"].is_object());
511        assert!(json["steps"].is_array());
512        assert!(json["anchors"].is_array());
513        assert!(json["tools"].is_array());
514        assert!(json["compilation"].is_object());
515        assert!(json["compilation"].is_object());
516    }
517
518    #[test]
519    fn flow_summary_serializable() {
520        let summary = FlowSummary {
521            name: "TestFlow".to_string(),
522            source_file: "test.axon".to_string(),
523            source_hash: "abc".to_string(),
524            step_count: 3,
525            has_anchors: true,
526            has_tools: false,
527        };
528        let json = serde_json::to_value(&summary).unwrap();
529        assert_eq!(json["name"], "TestFlow");
530        assert_eq!(json["step_count"], 3);
531        assert_eq!(json["has_anchors"], true);
532        assert_eq!(json["has_tools"], false);
533    }
534
535    #[test]
536    fn step_info_details() {
537        let result = inspect_flow("Analyze", SAMPLE_SOURCE, "test.axon", "abc").unwrap();
538        let gather = &result.steps[0];
539        assert_eq!(gather.persona_ref, "Analyst");
540        assert!(!gather.has_tool_use);
541        assert!(!gather.has_probe);
542        assert!(!gather.has_reason);
543        assert!(!gather.has_weave);
544    }
545
546    #[test]
547    fn compilation_info_details() {
548        let result = inspect_flow("Analyze", SAMPLE_SOURCE, "test.axon", "abc").unwrap();
549        assert!(result.compilation.token_count > 0);
550        assert_eq!(result.compilation.anchor_count, 1);
551        assert_eq!(result.compilation.tool_count, 1);
552    }
553
554    #[test]
555    fn graph_format_parsing() {
556        assert_eq!(GraphFormat::from_str("dot"), GraphFormat::Dot);
557        assert_eq!(GraphFormat::from_str("mermaid"), GraphFormat::Mermaid);
558        assert_eq!(GraphFormat::from_str("MERMAID"), GraphFormat::Mermaid);
559        assert_eq!(GraphFormat::from_str("unknown"), GraphFormat::Dot); // default
560        assert_eq!(GraphFormat::Dot.content_type(), "text/vnd.graphviz");
561        assert_eq!(GraphFormat::Mermaid.content_type(), "text/plain");
562    }
563
564    #[test]
565    fn export_flow_graph_dot() {
566        let result = export_flow_graph("Analyze", SAMPLE_SOURCE, "test.axon", GraphFormat::Dot);
567        assert!(result.is_ok());
568        let export = result.unwrap();
569        assert_eq!(export.flow_name, "Analyze");
570        assert_eq!(export.format, "dot");
571        assert!(export.graph.contains("digraph"));
572        assert!(export.graph.contains("gather"));
573        assert!(export.graph.contains("conclude"));
574        assert!(export.node_count >= 2);
575    }
576
577    #[test]
578    fn export_flow_graph_mermaid() {
579        let result = export_flow_graph("Analyze", SAMPLE_SOURCE, "test.axon", GraphFormat::Mermaid);
580        assert!(result.is_ok());
581        let export = result.unwrap();
582        assert_eq!(export.format, "mermaid");
583        assert!(export.graph.contains("graph TD"));
584        assert!(export.graph.contains("gather"));
585        assert!(export.graph.contains("conclude"));
586    }
587
588    #[test]
589    fn export_flow_graph_not_found() {
590        let result = export_flow_graph("NonExistent", SAMPLE_SOURCE, "test.axon", GraphFormat::Dot);
591        assert!(result.is_err());
592        assert!(result.unwrap_err().contains("not found"));
593    }
594
595    #[test]
596    fn graph_export_serializable() {
597        let result = export_flow_graph("Analyze", SAMPLE_SOURCE, "test.axon", GraphFormat::Dot).unwrap();
598        let json = serde_json::to_value(&result).unwrap();
599        assert_eq!(json["flow_name"], "Analyze");
600        assert_eq!(json["format"], "dot");
601        assert!(json["graph"].as_str().unwrap().contains("digraph"));
602        assert!(json["node_count"].as_u64().unwrap() >= 2);
603    }
604}