Skip to main content

axon/
plan_export.rs

1//! Execution plan export — JSONB-compatible structured output.
2//!
3//! Produces a self-describing JSON representation of the execution plan
4//! before execution begins. Designed for external consumption by:
5//!   - PostgreSQL JSONB columns (stable schema, predictable paths)
6//!   - CI/CD pipelines (plan review before execution)
7//!   - Dashboards and visualization tools
8//!   - External orchestrators
9//!
10//! Every exported JSON document includes a `_schema` header with:
11//!   - `type`: document type identifier (e.g., "axon.plan", "axon.report")
12//!   - `version`: schema version for backwards compatibility
13//!   - `axon_version`: AXON compiler version that produced it
14//!
15//! JSONB path conventions:
16//!   $.units[*].flow_name          — all flow names
17//!   $.units[*].steps[*].name      — all step names
18//!   $.dependencies.parallel_groups — parallelizable step groups
19//!   $.tools.registered[*].name    — all registered tool names
20
21use serde::Serialize;
22
23// ── Schema metadata ────────────────────────────────────────────────────────
24
25/// Schema metadata header — included in every exported JSON document.
26#[derive(Debug, Clone, Serialize)]
27pub struct SchemaHeader {
28    /// Document type identifier.
29    #[serde(rename = "type")]
30    pub doc_type: String,
31    /// Schema version (semver).
32    pub version: String,
33    /// AXON version that produced this document.
34    pub axon_version: String,
35}
36
37impl SchemaHeader {
38    pub fn new(doc_type: &str) -> Self {
39        SchemaHeader {
40            doc_type: doc_type.to_string(),
41            version: "1.0.0".to_string(),
42            axon_version: crate::runner::AXON_VERSION.to_string(),
43        }
44    }
45}
46
47// ── Plan export structures ─────────────────────────────────────────────────
48
49/// Exported execution plan — the full pre-execution view.
50#[derive(Debug, Clone, Serialize)]
51pub struct PlanExport {
52    pub _schema: SchemaHeader,
53    pub source_file: String,
54    pub backend: String,
55    pub units: Vec<PlanUnit>,
56    pub tools: PlanTools,
57    pub dependencies: PlanDependencies,
58    pub summary: PlanSummary,
59}
60
61/// A unit in the exported plan.
62#[derive(Debug, Clone, Serialize)]
63pub struct PlanUnit {
64    pub flow_name: String,
65    pub persona_name: String,
66    pub context_name: String,
67    pub effort: String,
68    pub anchor_count: usize,
69    pub anchors: Vec<String>,
70    pub steps: Vec<PlanStep>,
71}
72
73/// A step in the exported plan.
74#[derive(Debug, Clone, Serialize)]
75pub struct PlanStep {
76    pub name: String,
77    pub step_type: String,
78    pub prompt_preview: String,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub tool_argument: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub memory_expression: Option<String>,
83    pub depends_on: Vec<String>,
84    pub is_root: bool,
85}
86
87/// Tool registry summary in the exported plan.
88#[derive(Debug, Clone, Serialize)]
89pub struct PlanTools {
90    pub total: usize,
91    pub builtin: Vec<String>,
92    pub program: Vec<String>,
93    pub registered: Vec<PlanToolEntry>,
94}
95
96/// A tool entry in the exported plan.
97#[derive(Debug, Clone, Serialize)]
98pub struct PlanToolEntry {
99    pub name: String,
100    pub provider: String,
101    pub source: String,
102    #[serde(skip_serializing_if = "String::is_empty")]
103    pub output_schema: String,
104    #[serde(skip_serializing_if = "Vec::is_empty")]
105    pub effect_row: Vec<String>,
106}
107
108/// Dependency analysis summary in the exported plan.
109#[derive(Debug, Clone, Serialize)]
110pub struct PlanDependencies {
111    pub max_depth: usize,
112    pub parallel_groups: Vec<Vec<String>>,
113    pub unresolved_refs: Vec<UnresolvedRef>,
114}
115
116/// An unresolved variable reference.
117#[derive(Debug, Clone, Serialize)]
118pub struct UnresolvedRef {
119    pub step: String,
120    pub variable: String,
121}
122
123/// Plan-level summary.
124#[derive(Debug, Clone, Serialize)]
125pub struct PlanSummary {
126    pub total_units: usize,
127    pub total_steps: usize,
128    pub total_anchors: usize,
129    pub total_tools: usize,
130    pub has_parallel_steps: bool,
131    pub has_unresolved_refs: bool,
132}
133
134// ── Plan builder ───────────────────────────────────────────────────────────
135
136/// Build a plan export from execution components.
137pub struct PlanBuilder;
138
139impl PlanBuilder {
140    /// Build the plan export from components.
141    pub fn build(
142        source_file: &str,
143        backend: &str,
144        units: &[PlanUnit],
145        tools: PlanTools,
146        deps: PlanDependencies,
147    ) -> PlanExport {
148        let total_steps: usize = units.iter().map(|u| u.steps.len()).sum();
149        let total_anchors: usize = units.iter().map(|u| u.anchor_count).sum();
150
151        PlanExport {
152            _schema: SchemaHeader::new("axon.plan"),
153            source_file: source_file.to_string(),
154            backend: backend.to_string(),
155            units: units.to_vec(),
156            tools: tools.clone(),
157            dependencies: deps.clone(),
158            summary: PlanSummary {
159                total_units: units.len(),
160                total_steps,
161                total_anchors,
162                total_tools: tools.total,
163                has_parallel_steps: !deps.parallel_groups.is_empty(),
164                has_unresolved_refs: !deps.unresolved_refs.is_empty(),
165            },
166        }
167    }
168
169    /// Serialize to JSON string.
170    pub fn to_json(plan: &PlanExport) -> String {
171        serde_json::to_string_pretty(plan).unwrap_or_else(|e| {
172            format!("{{\"error\": \"serialization failed: {e}\"}}")
173        })
174    }
175}
176
177// ── JSONB path query ───────────────────────────────────────────────────────
178
179/// Simple JSONB-style path query on a serde_json::Value.
180///
181/// Supports a subset of JSONPath:
182///   $.field           — object field access
183///   $.field[N]        — array index access
184///   $.field[*]        — array wildcard (returns all elements)
185///   $.a.b.c           — nested field access
186///
187/// Returns a Vec of matched values.
188pub fn jsonb_query(value: &serde_json::Value, path: &str) -> Vec<serde_json::Value> {
189    let path = path.strip_prefix("$.").unwrap_or(path.strip_prefix('$').unwrap_or(path));
190    if path.is_empty() {
191        return vec![value.clone()];
192    }
193
194    let segments = parse_path(path);
195    let mut current = vec![value.clone()];
196
197    for seg in &segments {
198        let mut next = Vec::new();
199        for val in &current {
200            match seg {
201                PathSegment::Field(name) => {
202                    if let Some(v) = val.get(name.as_str()) {
203                        next.push(v.clone());
204                    }
205                }
206                PathSegment::Index(idx) => {
207                    if let Some(v) = val.get(*idx) {
208                        next.push(v.clone());
209                    }
210                }
211                PathSegment::Wildcard => {
212                    if let Some(arr) = val.as_array() {
213                        next.extend(arr.iter().cloned());
214                    }
215                }
216            }
217        }
218        current = next;
219    }
220
221    current
222}
223
224#[derive(Debug)]
225enum PathSegment {
226    Field(String),
227    Index(usize),
228    Wildcard,
229}
230
231fn parse_path(path: &str) -> Vec<PathSegment> {
232    let mut segments = Vec::new();
233    let mut remaining = path;
234
235    while !remaining.is_empty() {
236        // Strip leading dot
237        remaining = remaining.strip_prefix('.').unwrap_or(remaining);
238        if remaining.is_empty() {
239            break;
240        }
241
242        // Check for bracket notation
243        if let Some(bracket_start) = remaining.find('[') {
244            // Field before bracket
245            let field = &remaining[..bracket_start];
246            if !field.is_empty() {
247                segments.push(PathSegment::Field(field.to_string()));
248            }
249
250            // Parse bracket content
251            if let Some(bracket_end) = remaining[bracket_start..].find(']') {
252                let inner = &remaining[bracket_start + 1..bracket_start + bracket_end];
253                if inner == "*" {
254                    segments.push(PathSegment::Wildcard);
255                } else if let Ok(idx) = inner.parse::<usize>() {
256                    segments.push(PathSegment::Index(idx));
257                }
258                remaining = &remaining[bracket_start + bracket_end + 1..];
259            } else {
260                break;
261            }
262        } else {
263            // Find next dot or end
264            let end = remaining.find('.').unwrap_or(remaining.len());
265            let field = &remaining[..end];
266            if !field.is_empty() {
267                segments.push(PathSegment::Field(field.to_string()));
268            }
269            remaining = &remaining[end..];
270        }
271    }
272
273    segments
274}
275
276// ── Tests ──────────────────────────────────────────────────────────────────
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn schema_header_defaults() {
284        let h = SchemaHeader::new("axon.plan");
285        assert_eq!(h.doc_type, "axon.plan");
286        assert_eq!(h.version, "1.0.0");
287        assert!(!h.axon_version.is_empty());
288    }
289
290    #[test]
291    fn plan_builder_empty() {
292        let plan = PlanBuilder::build(
293            "test.axon",
294            "anthropic",
295            &[],
296            PlanTools {
297                total: 2,
298                builtin: vec!["Calculator".into(), "DateTimeTool".into()],
299                program: vec![],
300                registered: vec![],
301            },
302            PlanDependencies {
303                max_depth: 0,
304                parallel_groups: vec![],
305                unresolved_refs: vec![],
306            },
307        );
308
309        assert_eq!(plan._schema.doc_type, "axon.plan");
310        assert_eq!(plan.source_file, "test.axon");
311        assert_eq!(plan.summary.total_units, 0);
312        assert_eq!(plan.summary.total_steps, 0);
313        assert!(!plan.summary.has_parallel_steps);
314    }
315
316    #[test]
317    fn plan_builder_with_units() {
318        let units = vec![PlanUnit {
319            flow_name: "Analyze".into(),
320            persona_name: "Expert".into(),
321            context_name: "Review".into(),
322            effort: "high".into(),
323            anchor_count: 1,
324            anchors: vec!["NoHallucination".into()],
325            steps: vec![
326                PlanStep {
327                    name: "Extract".into(),
328                    step_type: "step".into(),
329                    prompt_preview: "Extract entities".into(),
330                    tool_argument: None,
331                    memory_expression: None,
332                    depends_on: vec![],
333                    is_root: true,
334                },
335                PlanStep {
336                    name: "Assess".into(),
337                    step_type: "step".into(),
338                    prompt_preview: "Assess ${Extract}".into(),
339                    tool_argument: None,
340                    memory_expression: None,
341                    depends_on: vec!["Extract".into()],
342                    is_root: false,
343                },
344            ],
345        }];
346
347        let plan = PlanBuilder::build(
348            "contract.axon",
349            "anthropic",
350            &units,
351            PlanTools {
352                total: 2,
353                builtin: vec!["Calculator".into()],
354                program: vec![],
355                registered: vec![],
356            },
357            PlanDependencies {
358                max_depth: 1,
359                parallel_groups: vec![],
360                unresolved_refs: vec![],
361            },
362        );
363
364        assert_eq!(plan.summary.total_units, 1);
365        assert_eq!(plan.summary.total_steps, 2);
366        assert_eq!(plan.summary.total_anchors, 1);
367    }
368
369    #[test]
370    fn plan_serializes_to_json() {
371        let plan = PlanBuilder::build(
372            "test.axon",
373            "anthropic",
374            &[],
375            PlanTools { total: 0, builtin: vec![], program: vec![], registered: vec![] },
376            PlanDependencies { max_depth: 0, parallel_groups: vec![], unresolved_refs: vec![] },
377        );
378        let json = PlanBuilder::to_json(&plan);
379        assert!(json.contains("\"_schema\""));
380        assert!(json.contains("\"axon.plan\""));
381        assert!(json.contains("\"version\""));
382    }
383
384    #[test]
385    fn plan_json_has_schema_header() {
386        let plan = PlanBuilder::build(
387            "test.axon",
388            "anthropic",
389            &[],
390            PlanTools { total: 0, builtin: vec![], program: vec![], registered: vec![] },
391            PlanDependencies { max_depth: 0, parallel_groups: vec![], unresolved_refs: vec![] },
392        );
393        let json = PlanBuilder::to_json(&plan);
394        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
395
396        assert_eq!(parsed["_schema"]["type"], "axon.plan");
397        assert_eq!(parsed["_schema"]["version"], "1.0.0");
398        assert!(parsed["_schema"]["axon_version"].is_string());
399    }
400
401    // ── JSONB path query tests ─────────────────────────────────────
402
403    #[test]
404    fn jsonb_query_simple_field() {
405        let val: serde_json::Value = serde_json::json!({"name": "test", "version": 1});
406        let results = jsonb_query(&val, "$.name");
407        assert_eq!(results.len(), 1);
408        assert_eq!(results[0], "test");
409    }
410
411    #[test]
412    fn jsonb_query_nested_field() {
413        let val: serde_json::Value = serde_json::json!({"a": {"b": {"c": 42}}});
414        let results = jsonb_query(&val, "$.a.b.c");
415        assert_eq!(results.len(), 1);
416        assert_eq!(results[0], 42);
417    }
418
419    #[test]
420    fn jsonb_query_array_index() {
421        let val: serde_json::Value = serde_json::json!({"items": [10, 20, 30]});
422        let results = jsonb_query(&val, "$.items[1]");
423        assert_eq!(results.len(), 1);
424        assert_eq!(results[0], 20);
425    }
426
427    #[test]
428    fn jsonb_query_wildcard() {
429        let val: serde_json::Value = serde_json::json!({"units": [
430            {"flow_name": "A"},
431            {"flow_name": "B"},
432        ]});
433        let results = jsonb_query(&val, "$.units[*].flow_name");
434        assert_eq!(results.len(), 2);
435        assert_eq!(results[0], "A");
436        assert_eq!(results[1], "B");
437    }
438
439    #[test]
440    fn jsonb_query_missing_field() {
441        let val: serde_json::Value = serde_json::json!({"name": "test"});
442        let results = jsonb_query(&val, "$.nonexistent");
443        assert!(results.is_empty());
444    }
445
446    #[test]
447    fn jsonb_query_root() {
448        let val: serde_json::Value = serde_json::json!(42);
449        let results = jsonb_query(&val, "$");
450        assert_eq!(results.len(), 1);
451        assert_eq!(results[0], 42);
452    }
453
454    #[test]
455    fn jsonb_query_nested_wildcard() {
456        let val: serde_json::Value = serde_json::json!({
457            "units": [
458                {"steps": [{"name": "A"}, {"name": "B"}]},
459                {"steps": [{"name": "C"}]},
460            ]
461        });
462        let results = jsonb_query(&val, "$.units[*].steps[*].name");
463        assert_eq!(results.len(), 3);
464        assert_eq!(results[0], "A");
465        assert_eq!(results[1], "B");
466        assert_eq!(results[2], "C");
467    }
468}