Skip to main content

flow_application/
dsl_semantics.rs

1//! Post-parse semantic checks for model-generated Flow graphs.
2//!
3//! The DSL parser accepts syntactically valid documents that still violate
4//! runtime contracts (e.g. `utility` nodes with `actionId: run-command`).
5//! The Generate tab runs these checks after `flow_dsl::parse` so local/cloud
6//! models get a corrective retry instead of shipping runnable-but-broken flows.
7
8use std::collections::HashSet;
9
10use flow_domain::graph::{FlowGraph, FlowNode};
11use serde_json::Value;
12
13const UTILITY_ACTIONS: &[&str] = &["sleep", "log", "set-variable", "merge", "cron"];
14const AI_PROVIDERS: &[&str] = &["local", "claude", "openai", "gemini", "nvidia", "deepseek"];
15
16/// Fix common model mistakes before re-serialization (self-loop edges, missing
17/// `commandName`, implicit adapter on canvas node types).
18pub fn repair_generated_graph(g: &mut FlowGraph) {
19    repair_edges(g);
20    for node in &mut g.nodes {
21        repair_node(node);
22    }
23}
24
25fn repair_edges(g: &mut FlowGraph) {
26    let mut seen = HashSet::new();
27    g.edges.retain(|e| {
28        if e.source == e.target {
29            return false;
30        }
31        let key = (
32            e.source.clone(),
33            e.target.clone(),
34            format!("{:?}", e.outcome),
35        );
36        if seen.contains(&key) {
37            return false;
38        }
39        seen.insert(key);
40        true
41    });
42}
43
44fn repair_node(node: &mut FlowNode) {
45    match node.node_type.as_str() {
46        "githubCommand" => {
47            let Some(obj) = node.data.as_object_mut() else {
48                return;
49            };
50            // Preset canvas node for the GitHub CLI: route to the generic cli
51            // adapter, stamping the binary so the runtime knows what to run.
52            if !obj.contains_key("adapter") {
53                obj.insert("adapter".into(), Value::String("cli".into()));
54            }
55            if !obj.contains_key("bin") {
56                obj.insert("bin".into(), Value::String("gh".into()));
57            }
58            if !obj.contains_key("actionId") {
59                obj.insert("actionId".into(), Value::String("cli-tool".into()));
60            }
61            if let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) {
62                if !obj.contains_key("commandName") {
63                    let base = cmd.split(" --").next().unwrap_or(cmd).trim();
64                    obj.insert("commandName".into(), Value::String(base.to_string()));
65                }
66            }
67        }
68        "zoweCommand" => {
69            let Some(obj) = node.data.as_object_mut() else {
70                return;
71            };
72            // Preset canvas node for the Zowe CLI: route to the generic cli adapter.
73            if !obj.contains_key("adapter") {
74                obj.insert("adapter".into(), Value::String("cli".into()));
75            }
76            if !obj.contains_key("bin") {
77                obj.insert("bin".into(), Value::String("zowe".into()));
78            }
79            if !obj.contains_key("actionId") {
80                obj.insert("actionId".into(), Value::String("cli-tool".into()));
81            }
82        }
83        "action" if obj_adapter(node) == Some("cli") => {
84            let Some(obj) = node.data.as_object_mut() else {
85                return;
86            };
87            if let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) {
88                if !obj.contains_key("commandName") {
89                    let base = cmd.split(" --").next().unwrap_or(cmd).trim();
90                    obj.insert("commandName".into(), Value::String(base.to_string()));
91                }
92            }
93        }
94        _ => {}
95    }
96}
97
98fn obj_adapter(node: &FlowNode) -> Option<&str> {
99    node.data.get("adapter").and_then(|v| v.as_str())
100}
101
102/// Return human-readable issues. Empty means the graph is acceptable.
103pub fn validate_generated_graph(g: &FlowGraph) -> Vec<String> {
104    let mut issues = Vec::new();
105    for node in &g.nodes {
106        issues.extend(validate_node(node));
107    }
108    issues.extend(validate_edges(g));
109    issues
110}
111
112fn validate_edges(g: &FlowGraph) -> Vec<String> {
113    let mut issues = Vec::new();
114    for e in &g.edges {
115        if e.source == e.target {
116            issues.push(format!(
117                "edge `{}` --> `{}` is a self-loop; remove duplicate or meaningless cycles",
118                e.source, e.target
119            ));
120        }
121    }
122    issues
123}
124
125fn validate_node(node: &FlowNode) -> Vec<String> {
126    match node.node_type.as_str() {
127        "utility" => validate_utility_node(node),
128        "ai" => validate_ai_node(node),
129        "agentic" => validate_agentic_node(node),
130        "service" => validate_service_node(node),
131        "action" => validate_action_node(node),
132        _ => Vec::new(),
133    }
134}
135
136fn validate_utility_node(node: &FlowNode) -> Vec<String> {
137    let mut issues = Vec::new();
138    let action_id = str_field(node, "actionId").unwrap_or("");
139    if !UTILITY_ACTIONS.contains(&action_id) {
140        issues.push(format!(
141            "utility node `{}` has actionId {:?}; utility only supports {:?}. \
142             Shell/git commands belong on action nodes with adapter: \"shell\" \
143             (e.g. actionId: \"git\" or \"run-command\"). CLI tools belong on \
144             action nodes with adapter: \"cli\" actionId: \"cli-tool\" and a `bin` field.",
145            node.id, action_id, UTILITY_ACTIONS
146        ));
147    }
148    if node.data.get("command").is_some() {
149        issues.push(format!(
150            "utility node `{}` must not have a `command` field - that is shell/cli \
151             adapter configuration, not utility.",
152            node.id
153        ));
154    }
155    issues
156}
157
158fn validate_ai_node(node: &FlowNode) -> Vec<String> {
159    let mut issues = Vec::new();
160    let provider = str_field(node, "provider").unwrap_or("");
161    if !provider.is_empty() && !AI_PROVIDERS.contains(&provider) {
162        issues.push(format!(
163            "ai node `{}` has unknown provider {:?}; expected one of {:?} (`local` \
164             runs on-device; the others are cloud providers). CLI tools run on action \
165             nodes (adapter: \"cli\" actionId: \"cli-tool\" with a `bin` field), not as \
166             an AI provider.",
167            node.id, provider, AI_PROVIDERS
168        ));
169    }
170    let fallback = str_field(node, "fallbackProvider").unwrap_or("");
171    if !fallback.is_empty() && !AI_PROVIDERS.contains(&fallback) {
172        issues.push(format!(
173            "ai node `{}` has unknown fallbackProvider {:?}; expected one of {:?}.",
174            node.id, fallback, AI_PROVIDERS
175        ));
176    }
177    for forbidden in ["actionId", "command", "adapter", "args"] {
178        if node.data.get(forbidden).is_some() {
179            issues.push(format!(
180                "ai node `{}` must not have `{}` - that field belongs on action \
181                 nodes (shell/fs/cli adapters).",
182                node.id, forbidden
183            ));
184        }
185    }
186    issues
187}
188
189fn validate_agentic_node(node: &FlowNode) -> Vec<String> {
190    let mut issues = Vec::new();
191    if str_field(node, "modelId").unwrap_or("").is_empty() {
192        issues.push(format!(
193            "agentic node `{}` must set `modelId` (the model used to generate the flow).",
194            node.id
195        ));
196    }
197    let provider = str_field(node, "provider").unwrap_or("");
198    if !provider.is_empty() && !AI_PROVIDERS.contains(&provider) {
199        issues.push(format!(
200            "agentic node `{}` has unknown provider {:?}; expected one of {:?}.",
201            node.id, provider, AI_PROVIDERS
202        ));
203    }
204    issues
205}
206
207fn validate_service_node(node: &FlowNode) -> Vec<String> {
208    let mut issues = Vec::new();
209    if str_field(node, "operation").unwrap_or("").is_empty() {
210        issues.push(format!(
211            "service node `{}` must set `operation` (the API operation to invoke, from \
212             the installed service's catalog descriptor).",
213            node.id
214        ));
215    }
216    issues
217}
218
219fn validate_action_node(node: &FlowNode) -> Vec<String> {
220    let mut issues = Vec::new();
221    let adapter = str_field(node, "adapter").unwrap_or("");
222    if adapter.is_empty() {
223        if node.data.get("command").is_some()
224            || node.data.get("args").is_some()
225            || matches!(
226                str_field(node, "actionId"),
227                Some("run-command" | "git" | "npm" | "pnpm" | "cargo" | "kubectl" | "curl")
228            )
229        {
230            issues.push(format!(
231                "action node `{}` looks like a shell command but has no adapter field; \
232                 set adapter: \"shell\" and a valid actionId.",
233                node.id
234            ));
235        }
236        if str_field(node, "actionId") == Some("cli-tool") {
237            issues.push(format!(
238                "action node `{}` has actionId \"cli-tool\" but no adapter; use \
239                 adapter: \"cli\" with a `bin` field naming the tool.",
240                node.id
241            ));
242        }
243    }
244    issues
245}
246
247fn str_field<'a>(node: &'a FlowNode, key: &str) -> Option<&'a str> {
248    node.data.get(key).and_then(|v| v.as_str())
249}
250
251#[cfg(test)]
252#[path = "dsl_semantics_tests/mod.rs"]
253mod tests;