Skip to main content

runx_parser/graph/
validate.rs

1use 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}