Skip to main content

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 =
112        load_operation_input_schema(&config.manifest_path, &config.manifest, &operation)?;
113
114    validate_config_schema(&config.schema)
115        .map_err(|err| anyhow!("config_schema failed validation: {err}"))?;
116
117    let fields = collect_fields(&input_schema)?;
118
119    let default_flow = render_default_flow(component_id, component_name, &operation, &fields)?;
120    let custom_flow = render_custom_flow(component_id, component_name, &operation, &fields)?;
121
122    let mut manifest = config.manifest.clone();
123    let manifest_obj = manifest
124        .as_object_mut()
125        .ok_or_else(|| anyhow!("manifest must be a JSON object"))?;
126    let dev_flows_entry = manifest_obj
127        .entry("dev_flows")
128        .or_insert_with(|| JsonValue::Object(JsonMap::new()));
129    let dev_flows = dev_flows_entry
130        .as_object_mut()
131        .ok_or_else(|| anyhow!("dev_flows must be an object"))?;
132
133    let mut merged = BTreeMap::new();
134    for (key, value) in dev_flows.iter() {
135        if key != "custom" && key != "default" {
136            merged.insert(key.clone(), value.clone());
137        }
138    }
139    merged.insert(
140        "custom".to_string(),
141        json!({
142            "format": "flow-ir-json",
143            "graph": custom_flow,
144        }),
145    );
146    merged.insert(
147        "default".to_string(),
148        json!({
149            "format": "flow-ir-json",
150            "graph": default_flow,
151        }),
152    );
153
154    *dev_flows = merged.into_iter().collect();
155
156    Ok(FlowUpdateOutcome {
157        manifest,
158        result: FlowUpdateResult {
159            default_updated: true,
160            custom_updated: true,
161        },
162    })
163}
164
165fn collect_fields(config_schema: &JsonValue) -> Result<Vec<ConfigField>> {
166    let properties = config_schema
167        .get("properties")
168        .and_then(|value| value.as_object())
169        .ok_or_else(|| anyhow!("config_schema.properties must be an object"))?;
170    let required = config_schema
171        .get("required")
172        .and_then(|value| value.as_array())
173        .map(|values| {
174            values
175                .iter()
176                .filter_map(|v| v.as_str().map(str::to_string))
177                .collect::<HashSet<String>>()
178        })
179        .unwrap_or_default();
180
181    let mut fields = properties
182        .iter()
183        .map(|(name, schema)| ConfigField::from_schema(name, schema, required.contains(name)))
184        .collect::<Vec<_>>();
185    fields.sort_by(|a, b| a.name.cmp(&b.name));
186    Ok(fields)
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190enum FieldType {
191    String,
192    Number,
193    Integer,
194    Boolean,
195    Unknown,
196}
197
198impl FieldType {
199    fn from_schema(schema: &JsonValue) -> Self {
200        let type_value = schema.get("type");
201        match type_value {
202            Some(JsonValue::String(value)) => Self::from_type_str(value),
203            Some(JsonValue::Array(types)) => types
204                .iter()
205                .filter_map(|v| v.as_str())
206                .find_map(|value| {
207                    let field_type = Self::from_type_str(value);
208                    (field_type != FieldType::Unknown && value != "null").then_some(field_type)
209                })
210                .unwrap_or(FieldType::Unknown),
211            _ => FieldType::Unknown,
212        }
213    }
214
215    fn from_type_str(value: &str) -> Self {
216        match value {
217            "string" => FieldType::String,
218            "number" => FieldType::Number,
219            "integer" => FieldType::Integer,
220            "boolean" => FieldType::Boolean,
221            _ => FieldType::Unknown,
222        }
223    }
224}
225
226#[derive(Debug, Clone)]
227struct ConfigField {
228    name: String,
229    description: Option<String>,
230    field_type: FieldType,
231    enum_options: Vec<String>,
232    default_value: Option<JsonValue>,
233    required: bool,
234    hidden: bool,
235}
236
237impl ConfigField {
238    fn from_schema(name: &str, schema: &JsonValue, required: bool) -> Self {
239        let field_type = FieldType::from_schema(schema);
240        let description = schema
241            .get("description")
242            .and_then(|value| value.as_str())
243            .map(str::to_string);
244        let default_value = schema.get("default").cloned();
245        let enum_options = schema
246            .get("enum")
247            .and_then(|value| value.as_array())
248            .map(|values| {
249                values
250                    .iter()
251                    .map(|entry| {
252                        entry
253                            .as_str()
254                            .map(str::to_string)
255                            .unwrap_or_else(|| entry.to_string())
256                    })
257                    .collect::<Vec<_>>()
258            })
259            .unwrap_or_default();
260        let hidden = schema
261            .get("x_flow_hidden")
262            .and_then(|value| value.as_bool())
263            .unwrap_or(false);
264        Self {
265            name: name.to_string(),
266            description,
267            field_type,
268            enum_options,
269            default_value,
270            required,
271            hidden,
272        }
273    }
274
275    fn prompt(&self) -> String {
276        if let Some(desc) = &self.description {
277            return desc.clone();
278        }
279        humanize(&self.name)
280    }
281
282    fn question_type(&self) -> &'static str {
283        if !self.enum_options.is_empty() {
284            "enum"
285        } else {
286            match self.field_type {
287                FieldType::String => "string",
288                FieldType::Number | FieldType::Integer => "number",
289                FieldType::Boolean => "boolean",
290                FieldType::Unknown => "string",
291            }
292        }
293    }
294
295    fn is_string_like(&self) -> bool {
296        !self.enum_options.is_empty()
297            || matches!(self.field_type, FieldType::String | FieldType::Unknown)
298    }
299}
300
301fn humanize(raw: &str) -> String {
302    let mut result = raw
303        .replace(['_', '-'], " ")
304        .split_whitespace()
305        .map(|word| {
306            let mut chars = word.chars();
307            match chars.next() {
308                Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
309                None => String::new(),
310            }
311        })
312        .collect::<Vec<_>>()
313        .join(" ");
314    if !result.ends_with(':') && !result.is_empty() {
315        result.push(':');
316    }
317    result
318}
319
320fn render_default_flow(
321    component_id: &str,
322    component_name: &str,
323    operation: &str,
324    fields: &[ConfigField],
325) -> Result<JsonValue> {
326    let field_values = compute_default_fields(fields)?;
327
328    let emit_template = render_emit_template(component_name, operation, field_values);
329    let mut nodes = BTreeMap::new();
330    nodes.insert(
331        "emit_config".to_string(),
332        json!({
333            "template": emit_template,
334        }),
335    );
336
337    let doc = FlowDocument {
338        id: format!("{component_id}.default"),
339        kind: DEFAULT_KIND.to_string(),
340        description: format!("Auto-generated default config for {component_id}"),
341        nodes,
342    };
343
344    flow_to_value(&doc)
345}
346
347fn render_custom_flow(
348    component_id: &str,
349    component_name: &str,
350    operation: &str,
351    fields: &[ConfigField],
352) -> Result<JsonValue> {
353    let visible_fields = fields
354        .iter()
355        .filter(|field| !field.hidden)
356        .collect::<Vec<_>>();
357
358    let mut question_fields = Vec::new();
359    for field in &visible_fields {
360        let mut mapping = JsonMap::new();
361        mapping.insert("id".into(), JsonValue::String(field.name.clone()));
362        mapping.insert("prompt".into(), JsonValue::String(field.prompt()));
363        mapping.insert(
364            "type".into(),
365            JsonValue::String(field.question_type().to_string()),
366        );
367        if !field.enum_options.is_empty() {
368            mapping.insert(
369                "options".into(),
370                JsonValue::Array(
371                    field
372                        .enum_options
373                        .iter()
374                        .map(|value| JsonValue::String(value.clone()))
375                        .collect(),
376                ),
377            );
378        }
379        if let Some(default_value) = &field.default_value {
380            mapping.insert("default".into(), default_value.clone());
381        }
382        question_fields.push(JsonValue::Object(mapping));
383    }
384
385    let mut questions_inner = JsonMap::new();
386    questions_inner.insert("fields".into(), JsonValue::Array(question_fields));
387
388    let mut ask_node = JsonMap::new();
389    ask_node.insert("questions".into(), JsonValue::Object(questions_inner));
390    ask_node.insert(
391        "routing".into(),
392        JsonValue::Array(vec![json!({ "to": "emit_config" })]),
393    );
394
395    let emit_field_values = visible_fields
396        .iter()
397        .map(|field| EmitField {
398            name: field.name.clone(),
399            value: if field.is_string_like() {
400                EmitFieldValue::StateQuoted(field.name.clone())
401            } else {
402                EmitFieldValue::StateRaw(field.name.clone())
403            },
404        })
405        .collect::<Vec<_>>();
406    let emit_template = render_emit_template(component_name, operation, emit_field_values);
407
408    let mut nodes = BTreeMap::new();
409    nodes.insert("ask_config".to_string(), JsonValue::Object(ask_node));
410    nodes.insert(
411        "emit_config".to_string(),
412        json!({ "template": emit_template }),
413    );
414
415    let doc = FlowDocument {
416        id: format!("{component_id}.custom"),
417        kind: DEFAULT_KIND.to_string(),
418        description: format!("Auto-generated custom config for {component_id}"),
419        nodes,
420    };
421
422    flow_to_value(&doc)
423}
424
425fn render_emit_template(component_name: &str, operation: &str, fields: Vec<EmitField>) -> String {
426    let mut lines = Vec::new();
427    lines.push("{".to_string());
428    lines.push(format!("  \"node_id\": \"{component_name}\","));
429    lines.push("  \"node\": {".to_string());
430    lines.push(format!("    \"{operation}\": {{"));
431    lines.push("      \"input\": {".to_string());
432    for (idx, field) in fields.iter().enumerate() {
433        let suffix = if idx + 1 == fields.len() { "" } else { "," };
434        lines.push(format!(
435            "        \"{}\": {}{}",
436            field.name,
437            field.value.render(),
438            suffix
439        ));
440    }
441    lines.push("      }".to_string());
442    lines.push("    },".to_string());
443    lines.push("    \"routing\": [".to_string());
444    lines.push("      { \"to\": \"NEXT_NODE_PLACEHOLDER\" }".to_string());
445    lines.push("    ]".to_string());
446    lines.push("  }".to_string());
447    lines.push("}".to_string());
448    lines.join("\n")
449}
450
451pub(crate) fn manifest_component_id(manifest: &JsonValue) -> Result<&str> {
452    manifest
453        .get("id")
454        .and_then(|value| value.as_str())
455        .ok_or_else(|| anyhow!("component.manifest.json must contain a string `id` field"))
456}
457
458fn manifest_component_name(manifest: &JsonValue) -> Result<&str> {
459    manifest
460        .get("name")
461        .and_then(|value| value.as_str())
462        .ok_or_else(|| anyhow!("component.manifest.json must contain a string `name` field"))
463}
464
465fn resolve_node_kind(manifest: &JsonValue) -> Result<&'static str> {
466    let requested = manifest
467        .get("mode")
468        .or_else(|| manifest.get("kind"))
469        .and_then(|value| value.as_str());
470    let resolved = requested.unwrap_or(COMPONENT_EXEC_KIND);
471    if resolved == "tool" {
472        bail!("mode/kind `tool` is no longer supported for config flows");
473    }
474    if resolved != COMPONENT_EXEC_KIND {
475        bail!(
476            "unsupported config flow node kind `{resolved}`; allowed kinds: {COMPONENT_EXEC_KIND}"
477        );
478    }
479    Ok(COMPONENT_EXEC_KIND)
480}
481
482pub(crate) fn resolve_operation(manifest: &JsonValue, component_id: &str) -> Result<String> {
483    let missing_msg = || {
484        anyhow!(
485            "Component {component_id} has no operations; add at least one operation (e.g. handle_message)"
486        )
487    };
488    let operations_value = manifest.get("operations").ok_or_else(missing_msg)?;
489    let operations_array = operations_value
490        .as_array()
491        .ok_or_else(|| anyhow!("`operations` must be an array of objects"))?;
492    let mut operations = Vec::new();
493    for entry in operations_array {
494        let op = entry
495            .as_object()
496            .ok_or_else(|| anyhow!("`operations` entries must be objects"))?;
497        let name = op
498            .get("name")
499            .and_then(|value| value.as_str())
500            .ok_or_else(|| anyhow!("`operations` entries must include a string `name` field"))?;
501        if name.trim().is_empty() {
502            return Err(missing_msg());
503        }
504        let input_schema = op.get("input_schema").ok_or_else(|| {
505            anyhow!("`operations` entries must include input_schema and output_schema")
506        })?;
507        let output_schema = op.get("output_schema").ok_or_else(|| {
508            anyhow!("`operations` entries must include input_schema and output_schema")
509        })?;
510        if !input_schema.is_object() || !output_schema.is_object() {
511            return Err(anyhow!(
512                "`operations` input_schema/output_schema must be objects"
513            ));
514        }
515        operations.push(name.to_string());
516    }
517    if operations.is_empty() {
518        return Err(missing_msg());
519    }
520
521    let default_operation = manifest
522        .get("default_operation")
523        .and_then(|value| value.as_str());
524    let chosen = if let Some(default) = default_operation {
525        if default.trim().is_empty() {
526            return Err(anyhow!("default_operation cannot be empty"));
527        }
528        if operations.iter().any(|op| op == default) {
529            default.to_string()
530        } else {
531            return Err(anyhow!(
532                "default_operation `{default}` must match one of the declared operations"
533            ));
534        }
535    } else if operations.len() == 1 {
536        operations[0].clone()
537    } else {
538        return Err(anyhow!(
539            "Component {component_id} declares multiple operations {:?}; set `default_operation` to pick one",
540            operations
541        ));
542    };
543    Ok(chosen)
544}
545
546struct EmitField {
547    name: String,
548    value: EmitFieldValue,
549}
550
551enum EmitFieldValue {
552    Literal(String),
553    StateQuoted(String),
554    StateRaw(String),
555}
556
557impl EmitFieldValue {
558    fn render(&self) -> String {
559        match self {
560            EmitFieldValue::Literal(value) => value.clone(),
561            EmitFieldValue::StateQuoted(name) => format!("\"{{{{state.{name}}}}}\""),
562            EmitFieldValue::StateRaw(name) => format!("{{{{state.{name}}}}}"),
563        }
564    }
565}
566
567#[derive(Serialize)]
568struct FlowDocument {
569    id: String,
570    kind: String,
571    description: String,
572    nodes: BTreeMap<String, JsonValue>,
573}
574
575fn flow_to_value(doc: &FlowDocument) -> Result<JsonValue> {
576    serde_json::to_value(doc).context("failed to render flow to JSON")
577}
578
579fn write_manifest(manifest_path: &PathBuf, manifest: &JsonValue) -> Result<()> {
580    let formatted = serde_json::to_string_pretty(manifest)?;
581    fs::write(manifest_path, formatted + "\n")
582        .with_context(|| format!("failed to write {}", manifest_path.display()))
583}
584
585fn load_operation_input_schema(
586    manifest_path: &Path,
587    manifest: &JsonValue,
588    operation_name: &str,
589) -> Result<JsonValue> {
590    let manifest_dir = manifest_path
591        .parent()
592        .ok_or_else(|| anyhow!("manifest path has no parent: {}", manifest_path.display()))?;
593
594    let operations = manifest
595        .get("operations")
596        .and_then(JsonValue::as_array)
597        .ok_or_else(|| anyhow!("manifest.operations must be an array"))?;
598    let operation = operations
599        .iter()
600        .find(|op| op.get("name").and_then(JsonValue::as_str) == Some(operation_name))
601        .ok_or_else(|| anyhow!("operation `{operation_name}` not found in manifest.operations"))?;
602    let input_schema = operation
603        .get("input_schema")
604        .ok_or_else(|| anyhow!("operation `{operation_name}` is missing input_schema"))?;
605
606    // If input_schema contains a $ref, resolve it by reading the referenced file
607    if let Some(ref_path) = input_schema.get("$ref").and_then(JsonValue::as_str) {
608        let schema_path = manifest_dir.join(ref_path);
609        let text = fs::read_to_string(&schema_path)
610            .with_context(|| format!("failed to read {}", schema_path.display()))?;
611        serde_json::from_str(&text)
612            .with_context(|| format!("failed to parse {}", schema_path.display()))
613    } else {
614        // Inline schema - use it directly
615        Ok(input_schema.clone())
616    }
617}
618
619fn compute_default_fields(fields: &[ConfigField]) -> Result<Vec<EmitField>> {
620    let mut emit_fields = Vec::new();
621    for field in fields {
622        if field.required {
623            if let Some(default_value) = &field.default_value {
624                let literal = serde_json::to_string(default_value)
625                    .context("failed to serialize default value")?;
626                emit_fields.push(EmitField {
627                    name: field.name.clone(),
628                    value: EmitFieldValue::Literal(literal),
629                });
630            } else {
631                bail!(
632                    "Required field {} has no default; cannot generate default dev_flow. Provide defaults or use custom mode.",
633                    field.name
634                );
635            }
636        }
637    }
638    Ok(emit_fields)
639}