greentic_component/cmd/
flow.rs

1#![cfg(feature = "cli")]
2
3use std::collections::{BTreeMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow, bail};
8use clap::{Args, Subcommand};
9use component_manifest::validate_config_schema;
10use serde::Serialize;
11use serde_json::{Map as JsonMap, Value as JsonValue, json};
12
13use crate::config::{
14    ConfigInferenceOptions, ConfigOutcome, load_manifest_with_schema, resolve_manifest_path,
15};
16
17const DEFAULT_MANIFEST: &str = "component.manifest.json";
18const DEFAULT_KIND: &str = "component-config";
19pub(crate) const COMPONENT_EXEC_KIND: &str = "component.exec";
20
21#[derive(Subcommand, Debug, Clone)]
22pub enum FlowCommand {
23    /// Regenerate config flows and embed them into component.manifest.json
24    Update(FlowUpdateArgs),
25}
26
27#[derive(Args, Debug, Clone)]
28pub struct FlowUpdateArgs {
29    /// Path to component.manifest.json (or directory containing it)
30    #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
31    pub manifest: PathBuf,
32    /// Skip config inference; fail if config_schema is missing
33    #[arg(long = "no-infer-config")]
34    pub no_infer_config: bool,
35    /// Do not write inferred config_schema back to the manifest
36    #[arg(long = "no-write-schema")]
37    pub no_write_schema: bool,
38    /// Overwrite existing config_schema with inferred schema
39    #[arg(long = "force-write-schema")]
40    pub force_write_schema: bool,
41    /// Skip schema validation
42    #[arg(long = "no-validate")]
43    pub no_validate: bool,
44}
45
46pub fn run(command: FlowCommand) -> Result<()> {
47    match command {
48        FlowCommand::Update(args) => {
49            update(args)?;
50            Ok(())
51        }
52    }
53}
54
55#[derive(Debug, Clone, Copy, Default, Serialize)]
56pub struct FlowUpdateResult {
57    pub default_updated: bool,
58    pub custom_updated: bool,
59}
60
61#[derive(Debug)]
62pub struct FlowUpdateOutcome {
63    pub manifest: JsonValue,
64    pub result: FlowUpdateResult,
65}
66
67pub fn update(args: FlowUpdateArgs) -> Result<FlowUpdateResult> {
68    let manifest_path = resolve_manifest_path(&args.manifest);
69    let inference_opts = ConfigInferenceOptions {
70        allow_infer: !args.no_infer_config,
71        write_schema: !args.no_write_schema,
72        force_write_schema: args.force_write_schema,
73        validate: !args.no_validate,
74    };
75    let config = load_manifest_with_schema(&manifest_path, &inference_opts)?;
76    let FlowUpdateOutcome {
77        mut manifest,
78        result,
79    } = update_with_manifest(&config)?;
80
81    if !config.persist_schema {
82        manifest
83            .as_object_mut()
84            .map(|obj| obj.remove("config_schema"));
85    }
86
87    write_manifest(&manifest_path, &manifest)?;
88
89    if config.schema_written && config.persist_schema {
90        println!(
91            "Updated {} with inferred config_schema ({:?})",
92            manifest_path.display(),
93            config.source
94        );
95    }
96    println!(
97        "Updated dev_flows (default: {}, custom: {}) in {}",
98        result.default_updated,
99        result.custom_updated,
100        manifest_path.display()
101    );
102
103    Ok(result)
104}
105
106pub fn update_with_manifest(config: &ConfigOutcome) -> Result<FlowUpdateOutcome> {
107    let component_id = manifest_component_id(&config.manifest)?;
108    let component_name = manifest_component_name(&config.manifest)?;
109    let _node_kind = resolve_node_kind(&config.manifest)?;
110    let operation = resolve_operation(&config.manifest, component_id)?;
111    let input_schema = load_operation_input_schema(&config.manifest_path, &config.manifest)?;
112
113    validate_config_schema(&config.schema)
114        .map_err(|err| anyhow!("config_schema failed validation: {err}"))?;
115
116    let fields = collect_fields(&input_schema)?;
117
118    let default_flow = render_default_flow(component_id, component_name, &operation, &fields)?;
119    let custom_flow = render_custom_flow(component_id, component_name, &operation, &fields)?;
120
121    let mut manifest = config.manifest.clone();
122    let manifest_obj = manifest
123        .as_object_mut()
124        .ok_or_else(|| anyhow!("manifest must be a JSON object"))?;
125    let dev_flows_entry = manifest_obj
126        .entry("dev_flows")
127        .or_insert_with(|| JsonValue::Object(JsonMap::new()));
128    let dev_flows = dev_flows_entry
129        .as_object_mut()
130        .ok_or_else(|| anyhow!("dev_flows must be an object"))?;
131
132    let mut merged = BTreeMap::new();
133    for (key, value) in dev_flows.iter() {
134        if key != "custom" && key != "default" {
135            merged.insert(key.clone(), value.clone());
136        }
137    }
138    merged.insert(
139        "custom".to_string(),
140        json!({
141            "format": "flow-ir-json",
142            "graph": custom_flow,
143        }),
144    );
145    merged.insert(
146        "default".to_string(),
147        json!({
148            "format": "flow-ir-json",
149            "graph": default_flow,
150        }),
151    );
152
153    *dev_flows = merged.into_iter().collect();
154
155    Ok(FlowUpdateOutcome {
156        manifest,
157        result: FlowUpdateResult {
158            default_updated: true,
159            custom_updated: true,
160        },
161    })
162}
163
164fn collect_fields(config_schema: &JsonValue) -> Result<Vec<ConfigField>> {
165    let properties = config_schema
166        .get("properties")
167        .and_then(|value| value.as_object())
168        .ok_or_else(|| anyhow!("config_schema.properties must be an object"))?;
169    let required = config_schema
170        .get("required")
171        .and_then(|value| value.as_array())
172        .map(|values| {
173            values
174                .iter()
175                .filter_map(|v| v.as_str().map(str::to_string))
176                .collect::<HashSet<String>>()
177        })
178        .unwrap_or_default();
179
180    let mut fields = properties
181        .iter()
182        .map(|(name, schema)| ConfigField::from_schema(name, schema, required.contains(name)))
183        .collect::<Vec<_>>();
184    fields.sort_by(|a, b| a.name.cmp(&b.name));
185    Ok(fields)
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189enum FieldType {
190    String,
191    Number,
192    Integer,
193    Boolean,
194    Unknown,
195}
196
197impl FieldType {
198    fn from_schema(schema: &JsonValue) -> Self {
199        let type_value = schema.get("type");
200        match type_value {
201            Some(JsonValue::String(value)) => Self::from_type_str(value),
202            Some(JsonValue::Array(types)) => types
203                .iter()
204                .filter_map(|v| v.as_str())
205                .find_map(|value| {
206                    let field_type = Self::from_type_str(value);
207                    (field_type != FieldType::Unknown && value != "null").then_some(field_type)
208                })
209                .unwrap_or(FieldType::Unknown),
210            _ => FieldType::Unknown,
211        }
212    }
213
214    fn from_type_str(value: &str) -> Self {
215        match value {
216            "string" => FieldType::String,
217            "number" => FieldType::Number,
218            "integer" => FieldType::Integer,
219            "boolean" => FieldType::Boolean,
220            _ => FieldType::Unknown,
221        }
222    }
223}
224
225#[derive(Debug, Clone)]
226struct ConfigField {
227    name: String,
228    description: Option<String>,
229    field_type: FieldType,
230    enum_options: Vec<String>,
231    default_value: Option<JsonValue>,
232    required: bool,
233    hidden: bool,
234}
235
236impl ConfigField {
237    fn from_schema(name: &str, schema: &JsonValue, required: bool) -> Self {
238        let field_type = FieldType::from_schema(schema);
239        let description = schema
240            .get("description")
241            .and_then(|value| value.as_str())
242            .map(str::to_string);
243        let default_value = schema.get("default").cloned();
244        let enum_options = schema
245            .get("enum")
246            .and_then(|value| value.as_array())
247            .map(|values| {
248                values
249                    .iter()
250                    .map(|entry| {
251                        entry
252                            .as_str()
253                            .map(str::to_string)
254                            .unwrap_or_else(|| entry.to_string())
255                    })
256                    .collect::<Vec<_>>()
257            })
258            .unwrap_or_default();
259        let hidden = schema
260            .get("x_flow_hidden")
261            .and_then(|value| value.as_bool())
262            .unwrap_or(false);
263        Self {
264            name: name.to_string(),
265            description,
266            field_type,
267            enum_options,
268            default_value,
269            required,
270            hidden,
271        }
272    }
273
274    fn prompt(&self) -> String {
275        if let Some(desc) = &self.description {
276            return desc.clone();
277        }
278        humanize(&self.name)
279    }
280
281    fn question_type(&self) -> &'static str {
282        if !self.enum_options.is_empty() {
283            "enum"
284        } else {
285            match self.field_type {
286                FieldType::String => "string",
287                FieldType::Number | FieldType::Integer => "number",
288                FieldType::Boolean => "boolean",
289                FieldType::Unknown => "string",
290            }
291        }
292    }
293
294    fn is_string_like(&self) -> bool {
295        !self.enum_options.is_empty()
296            || matches!(self.field_type, FieldType::String | FieldType::Unknown)
297    }
298}
299
300fn humanize(raw: &str) -> String {
301    let mut result = raw
302        .replace(['_', '-'], " ")
303        .split_whitespace()
304        .map(|word| {
305            let mut chars = word.chars();
306            match chars.next() {
307                Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
308                None => String::new(),
309            }
310        })
311        .collect::<Vec<_>>()
312        .join(" ");
313    if !result.ends_with(':') && !result.is_empty() {
314        result.push(':');
315    }
316    result
317}
318
319fn render_default_flow(
320    component_id: &str,
321    component_name: &str,
322    operation: &str,
323    fields: &[ConfigField],
324) -> Result<JsonValue> {
325    let field_values = compute_default_fields(fields)?;
326
327    let emit_template = render_emit_template(component_name, operation, field_values);
328    let mut nodes = BTreeMap::new();
329    nodes.insert(
330        "emit_config".to_string(),
331        json!({
332            "template": emit_template,
333        }),
334    );
335
336    let doc = FlowDocument {
337        id: format!("{component_id}.default"),
338        kind: DEFAULT_KIND.to_string(),
339        description: format!("Auto-generated default config for {component_id}"),
340        nodes,
341    };
342
343    flow_to_value(&doc)
344}
345
346fn render_custom_flow(
347    component_id: &str,
348    component_name: &str,
349    operation: &str,
350    fields: &[ConfigField],
351) -> Result<JsonValue> {
352    let visible_fields = fields
353        .iter()
354        .filter(|field| !field.hidden)
355        .collect::<Vec<_>>();
356
357    let mut question_fields = Vec::new();
358    for field in &visible_fields {
359        let mut mapping = JsonMap::new();
360        mapping.insert("id".into(), JsonValue::String(field.name.clone()));
361        mapping.insert("prompt".into(), JsonValue::String(field.prompt()));
362        mapping.insert(
363            "type".into(),
364            JsonValue::String(field.question_type().to_string()),
365        );
366        if !field.enum_options.is_empty() {
367            mapping.insert(
368                "options".into(),
369                JsonValue::Array(
370                    field
371                        .enum_options
372                        .iter()
373                        .map(|value| JsonValue::String(value.clone()))
374                        .collect(),
375                ),
376            );
377        }
378        if let Some(default_value) = &field.default_value {
379            mapping.insert("default".into(), default_value.clone());
380        }
381        question_fields.push(JsonValue::Object(mapping));
382    }
383
384    let mut questions_inner = JsonMap::new();
385    questions_inner.insert("fields".into(), JsonValue::Array(question_fields));
386
387    let mut ask_node = JsonMap::new();
388    ask_node.insert("questions".into(), JsonValue::Object(questions_inner));
389    ask_node.insert(
390        "routing".into(),
391        JsonValue::Array(vec![json!({ "to": "emit_config" })]),
392    );
393
394    let emit_field_values = visible_fields
395        .iter()
396        .map(|field| EmitField {
397            name: field.name.clone(),
398            value: if field.is_string_like() {
399                EmitFieldValue::StateQuoted(field.name.clone())
400            } else {
401                EmitFieldValue::StateRaw(field.name.clone())
402            },
403        })
404        .collect::<Vec<_>>();
405    let emit_template = render_emit_template(component_name, operation, emit_field_values);
406
407    let mut nodes = BTreeMap::new();
408    nodes.insert("ask_config".to_string(), JsonValue::Object(ask_node));
409    nodes.insert(
410        "emit_config".to_string(),
411        json!({ "template": emit_template }),
412    );
413
414    let doc = FlowDocument {
415        id: format!("{component_id}.custom"),
416        kind: DEFAULT_KIND.to_string(),
417        description: format!("Auto-generated custom config for {component_id}"),
418        nodes,
419    };
420
421    flow_to_value(&doc)
422}
423
424fn render_emit_template(component_name: &str, operation: &str, fields: Vec<EmitField>) -> String {
425    let mut lines = Vec::new();
426    lines.push("{".to_string());
427    lines.push(format!("  \"node_id\": \"{component_name}\","));
428    lines.push("  \"node\": {".to_string());
429    lines.push(format!("    \"{operation}\": {{"));
430    lines.push("      \"input\": {".to_string());
431    for (idx, field) in fields.iter().enumerate() {
432        let suffix = if idx + 1 == fields.len() { "" } else { "," };
433        lines.push(format!(
434            "        \"{}\": {}{}",
435            field.name,
436            field.value.render(),
437            suffix
438        ));
439    }
440    lines.push("      }".to_string());
441    lines.push("    },".to_string());
442    lines.push("    \"routing\": [".to_string());
443    lines.push("      { \"to\": \"NEXT_NODE_PLACEHOLDER\" }".to_string());
444    lines.push("    ]".to_string());
445    lines.push("  }".to_string());
446    lines.push("}".to_string());
447    lines.join("\n")
448}
449
450pub(crate) fn manifest_component_id(manifest: &JsonValue) -> Result<&str> {
451    manifest
452        .get("id")
453        .and_then(|value| value.as_str())
454        .ok_or_else(|| anyhow!("component.manifest.json must contain a string `id` field"))
455}
456
457fn manifest_component_name(manifest: &JsonValue) -> Result<&str> {
458    manifest
459        .get("name")
460        .and_then(|value| value.as_str())
461        .ok_or_else(|| anyhow!("component.manifest.json must contain a string `name` field"))
462}
463
464fn resolve_node_kind(manifest: &JsonValue) -> Result<&'static str> {
465    let requested = manifest
466        .get("mode")
467        .or_else(|| manifest.get("kind"))
468        .and_then(|value| value.as_str());
469    let resolved = requested.unwrap_or(COMPONENT_EXEC_KIND);
470    if resolved == "tool" {
471        bail!("mode/kind `tool` is no longer supported for config flows");
472    }
473    if resolved != COMPONENT_EXEC_KIND {
474        bail!(
475            "unsupported config flow node kind `{resolved}`; allowed kinds: {COMPONENT_EXEC_KIND}"
476        );
477    }
478    Ok(COMPONENT_EXEC_KIND)
479}
480
481pub(crate) fn resolve_operation(manifest: &JsonValue, component_id: &str) -> Result<String> {
482    let missing_msg = || {
483        anyhow!(
484            "Component {component_id} has no operations; add at least one operation (e.g. handle_message)"
485        )
486    };
487    let operations_value = manifest.get("operations").ok_or_else(missing_msg)?;
488    let operations_array = operations_value
489        .as_array()
490        .ok_or_else(|| anyhow!("`operations` must be an array of objects"))?;
491    let mut operations = Vec::new();
492    for entry in operations_array {
493        let op = entry
494            .as_object()
495            .ok_or_else(|| anyhow!("`operations` entries must be objects"))?;
496        let name = op
497            .get("name")
498            .and_then(|value| value.as_str())
499            .ok_or_else(|| anyhow!("`operations` entries must include a string `name` field"))?;
500        if name.trim().is_empty() {
501            return Err(missing_msg());
502        }
503        let input_schema = op.get("input_schema").ok_or_else(|| {
504            anyhow!("`operations` entries must include input_schema and output_schema")
505        })?;
506        let output_schema = op.get("output_schema").ok_or_else(|| {
507            anyhow!("`operations` entries must include input_schema and output_schema")
508        })?;
509        if !input_schema.is_object() || !output_schema.is_object() {
510            return Err(anyhow!(
511                "`operations` input_schema/output_schema must be objects"
512            ));
513        }
514        operations.push(name.to_string());
515    }
516    if operations.is_empty() {
517        return Err(missing_msg());
518    }
519
520    let default_operation = manifest
521        .get("default_operation")
522        .and_then(|value| value.as_str());
523    let chosen = if let Some(default) = default_operation {
524        if default.trim().is_empty() {
525            return Err(anyhow!("default_operation cannot be empty"));
526        }
527        if operations.iter().any(|op| op == default) {
528            default.to_string()
529        } else {
530            return Err(anyhow!(
531                "default_operation `{default}` must match one of the declared operations"
532            ));
533        }
534    } else if operations.len() == 1 {
535        operations[0].clone()
536    } else {
537        return Err(anyhow!(
538            "Component {component_id} declares multiple operations {:?}; set `default_operation` to pick one",
539            operations
540        ));
541    };
542    Ok(chosen)
543}
544
545struct EmitField {
546    name: String,
547    value: EmitFieldValue,
548}
549
550enum EmitFieldValue {
551    Literal(String),
552    StateQuoted(String),
553    StateRaw(String),
554}
555
556impl EmitFieldValue {
557    fn render(&self) -> String {
558        match self {
559            EmitFieldValue::Literal(value) => value.clone(),
560            EmitFieldValue::StateQuoted(name) => format!("\"{{{{state.{name}}}}}\""),
561            EmitFieldValue::StateRaw(name) => format!("{{{{state.{name}}}}}"),
562        }
563    }
564}
565
566#[derive(Serialize)]
567struct FlowDocument {
568    id: String,
569    kind: String,
570    description: String,
571    nodes: BTreeMap<String, JsonValue>,
572}
573
574fn flow_to_value(doc: &FlowDocument) -> Result<JsonValue> {
575    serde_json::to_value(doc).context("failed to render flow to JSON")
576}
577
578fn write_manifest(manifest_path: &PathBuf, manifest: &JsonValue) -> Result<()> {
579    let formatted = serde_json::to_string_pretty(manifest)?;
580    fs::write(manifest_path, formatted + "\n")
581        .with_context(|| format!("failed to write {}", manifest_path.display()))
582}
583
584fn load_operation_input_schema(manifest_path: &Path, manifest: &JsonValue) -> Result<JsonValue> {
585    let manifest_dir = manifest_path
586        .parent()
587        .ok_or_else(|| anyhow!("manifest path has no parent: {}", manifest_path.display()))?;
588    let schema_path = manifest
589        .get("schemas")
590        .and_then(|entry| entry.get("input"))
591        .and_then(|value| value.as_str())
592        .map(|path| manifest_dir.join(path))
593        .unwrap_or_else(|| manifest_dir.join("schemas/io/input.schema.json"));
594    let text = fs::read_to_string(&schema_path)
595        .with_context(|| format!("failed to read {}", schema_path.display()))?;
596    serde_json::from_str(&text)
597        .with_context(|| format!("failed to parse {}", schema_path.display()))
598}
599
600fn compute_default_fields(fields: &[ConfigField]) -> Result<Vec<EmitField>> {
601    let mut emit_fields = Vec::new();
602    for field in fields {
603        if field.required {
604            if let Some(default_value) = &field.default_value {
605                let literal = serde_json::to_string(default_value)
606                    .context("failed to serialize default value")?;
607                emit_fields.push(EmitField {
608                    name: field.name.clone(),
609                    value: EmitFieldValue::Literal(literal),
610                });
611            } else {
612                bail!(
613                    "Required field {} has no default; cannot generate default dev_flow. Provide defaults or use custom mode.",
614                    field.name
615                );
616            }
617        }
618    }
619    Ok(emit_fields)
620}