runx_parser/graph/
validate.rs1use std::collections::BTreeSet;
2
3use runx_contracts::JsonObject;
4
5use super::fanout::{validate_fanout_groups, validate_fanout_step_bindings};
6use super::helpers::{
7 optional_string, required_array, required_object, required_string, validation_error,
8};
9use super::policy::validate_graph_policy;
10use super::step::validate_step;
11use super::types::{ExecutionGraph, RawGraphIr};
12use crate::{ParseError, ValidationError, assert_yaml_parity_subset};
13
14pub fn parse_graph_yaml(source: &str) -> Result<RawGraphIr, ParseError> {
15 assert_yaml_parity_subset("graph", source)?;
16 let document: JsonObject =
17 serde_norway::from_str(source).map_err(|error| ParseError::InvalidYaml {
18 field: "graph".to_owned(),
19 message: error.to_string(),
20 })?;
21 Ok(RawGraphIr { document })
22}
23
24pub fn validate_graph(raw: RawGraphIr) -> Result<ExecutionGraph, ValidationError> {
25 validate_graph_document(raw.document.clone(), Some(raw))
26}
27
28pub fn validate_graph_document(
29 document: JsonObject,
30 raw: Option<RawGraphIr>,
31) -> Result<ExecutionGraph, ValidationError> {
32 reject_unsupported_top_level(&document)?;
33
34 let name = required_string(document.get("name"), "name")?;
35 let owner = optional_string(document.get("owner"), "owner")?;
36 let charter_from = optional_string(document.get("charter_from"), "charter_from")?;
37 let raw_steps = required_array(document.get("steps"), "steps")?;
38 let fanout_groups = validate_fanout_groups(document.get("fanout"), "fanout")?;
39 let policy = validate_graph_policy(document.get("policy"), "policy")?;
40 let mut seen_step_ids = BTreeSet::new();
41 let mut steps = Vec::new();
42
43 for (index, raw_step) in raw_steps.iter().enumerate() {
44 let field = format!("steps.{index}");
45 let raw_step = required_object(Some(raw_step), &field)?;
46 let step = validate_step(raw_step, &field, &seen_step_ids, charter_from.is_some())?;
47 seen_step_ids.insert(step.id.clone());
48 steps.push(step);
49 }
50
51 validate_fanout_step_bindings(&steps, &fanout_groups)?;
52
53 Ok(ExecutionGraph {
54 name,
55 owner,
56 charter_from,
57 steps,
58 fanout_groups,
59 policy,
60 raw: raw.unwrap_or(RawGraphIr { document }),
61 })
62}
63
64fn reject_unsupported_top_level(document: &JsonObject) -> Result<(), ValidationError> {
65 for field in ["sync", "schedule", "schedules"] {
66 if document.contains_key(field) {
67 return Err(validation_error(format!(
68 "{field} is not supported by the local sequential graph runner."
69 )));
70 }
71 }
72 Ok(())
73}
74
75#[cfg(test)]
76mod tests {
77 use super::{parse_graph_yaml, validate_graph};
78
79 #[test]
80 fn inputs_reject_previous_step_output_references() -> Result<(), String> {
81 let raw = parse_graph_yaml(
82 r#"
83name: bad-input-ref
84steps:
85 - id: select
86 run:
87 type: agent-task
88 - id: review
89 run:
90 type: agent-task
91 inputs:
92 bounty: select.result
93"#,
94 )
95 .map_err(|error| error.to_string())?;
96 let error = validate_graph(raw)
97 .err()
98 .ok_or_else(|| "graph unexpectedly validated".to_owned())?;
99 let message = error.to_string();
100 assert!(message.contains("steps.1.inputs.bounty"));
101 assert!(message.contains("move it to context"));
102 Ok(())
103 }
104
105 #[test]
106 fn inputs_allow_literals_that_are_not_previous_step_refs() -> Result<(), String> {
107 let raw = parse_graph_yaml(
108 r#"
109name: literal-input
110steps:
111 - id: review
112 run:
113 type: agent-task
114 inputs:
115 literal: select.result
116 variable: $input.claim
117 url: https://example.com/a.b
118"#,
119 )
120 .map_err(|error| error.to_string())?;
121 validate_graph(raw).map_err(|error| error.to_string())?;
122 Ok(())
123 }
124
125 fn validate_yaml(source: &str) -> Result<crate::ExecutionGraph, String> {
126 let raw = parse_graph_yaml(source).map_err(|error| error.to_string())?;
127 validate_graph(raw).map_err(|error| error.to_string())
128 }
129
130 fn validate_err(source: &str) -> Result<String, String> {
131 let raw = parse_graph_yaml(source).map_err(|error| error.to_string())?;
132 validate_graph(raw)
133 .err()
134 .map(|error| error.to_string())
135 .ok_or_else(|| "graph unexpectedly validated".to_owned())
136 }
137
138 #[test]
139 fn charter_from_and_static_scope_mint_validate() -> Result<(), String> {
140 let graph = validate_yaml(
141 r#"
142name: mint-static
143charter_from: charter
144steps:
145 - id: dispatch
146 run:
147 type: agent-task
148 scopes:
149 - payments:spend
150 mint_authority:
151 source: static_scopes
152"#,
153 )?;
154 assert_eq!(graph.charter_from.as_deref(), Some("charter"));
155 let directive = graph.steps[0]
156 .mint_authority
157 .ok_or_else(|| "expected mint_authority".to_owned())?;
158 assert_eq!(directive.source, crate::MintScopeSource::StaticScopes);
159 Ok(())
160 }
161
162 #[test]
163 fn requested_scope_mint_validates() -> Result<(), String> {
164 let graph = validate_yaml(
165 r#"
166name: mint-dynamic
167charter_from: charter
168steps:
169 - id: dispatch
170 run:
171 type: agent-task
172 requested_scope_from: needed_scope
173 mint_authority:
174 source: requested_scope
175"#,
176 )?;
177 let step = &graph.steps[0];
178 assert_eq!(step.requested_scope_from.as_deref(), Some("needed_scope"));
179 assert_eq!(
180 step.mint_authority.map(|directive| directive.source),
181 Some(crate::MintScopeSource::RequestedScope)
182 );
183 Ok(())
184 }
185
186 #[test]
187 fn mint_authority_without_charter_is_rejected() -> Result<(), String> {
188 let message = validate_err(
189 r#"
190name: mint-no-charter
191steps:
192 - id: dispatch
193 run:
194 type: agent-task
195 scopes:
196 - payments:spend
197 mint_authority:
198 source: static_scopes
199"#,
200 )?;
201 assert!(message.contains("requires the graph to declare charter_from"));
202 Ok(())
203 }
204
205 #[test]
206 fn static_scopes_mint_rejects_requested_scope_from() -> Result<(), String> {
207 let message = validate_err(
208 r#"
209name: mint-two-sources
210charter_from: charter
211steps:
212 - id: dispatch
213 run:
214 type: agent-task
215 scopes:
216 - payments:spend
217 requested_scope_from: needed_scope
218 mint_authority:
219 source: static_scopes
220"#,
221 )?;
222 assert!(message.contains("must not declare requested_scope_from"));
223 Ok(())
224 }
225
226 #[test]
227 fn static_scopes_mint_requires_non_empty_scopes() -> Result<(), String> {
228 let message = validate_err(
229 r#"
230name: mint-no-scopes
231charter_from: charter
232steps:
233 - id: dispatch
234 run:
235 type: agent-task
236 mint_authority:
237 source: static_scopes
238"#,
239 )?;
240 assert!(message.contains("requires a non-empty scopes list"));
241 Ok(())
242 }
243
244 #[test]
245 fn requested_scope_mint_requires_input_key() -> Result<(), String> {
246 let message = validate_err(
247 r#"
248name: mint-missing-input
249charter_from: charter
250steps:
251 - id: dispatch
252 run:
253 type: agent-task
254 mint_authority:
255 source: requested_scope
256"#,
257 )?;
258 assert!(message.contains("requires requested_scope_from"));
259 Ok(())
260 }
261
262 #[test]
263 fn requested_scope_mint_rejects_static_scopes() -> Result<(), String> {
264 let message = validate_err(
265 r#"
266name: mint-mixed
267charter_from: charter
268steps:
269 - id: dispatch
270 run:
271 type: agent-task
272 scopes:
273 - payments:spend
274 requested_scope_from: needed_scope
275 mint_authority:
276 source: requested_scope
277"#,
278 )?;
279 assert!(message.contains("must not declare a static scopes list"));
280 Ok(())
281 }
282
283 #[test]
284 fn requested_scope_from_without_directive_is_rejected() -> Result<(), String> {
285 let message = validate_err(
286 r#"
287name: dangling-requested-scope
288charter_from: charter
289steps:
290 - id: dispatch
291 run:
292 type: agent-task
293 requested_scope_from: needed_scope
294"#,
295 )?;
296 assert!(message.contains("only valid with a mint_authority directive"));
297 Ok(())
298 }
299
300 #[test]
301 fn unknown_mint_source_is_rejected() -> Result<(), String> {
302 let message = validate_err(
303 r#"
304name: bad-source
305charter_from: charter
306steps:
307 - id: dispatch
308 run:
309 type: agent-task
310 scopes:
311 - payments:spend
312 mint_authority:
313 source: widen_everything
314"#,
315 )?;
316 assert!(message.contains("must be static_scopes or requested_scope"));
317 Ok(())
318 }
319}