flow_application/
dsl_semantics.rs1use 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
16pub 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 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 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
102pub 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;