Skip to main content

runx_parser/skill/
governance.rs

1use std::collections::BTreeMap;
2
3use runx_contracts::{JsonObject, JsonValue};
4use runx_core::policy::admit_agent_tool_ref;
5
6use crate::ValidationError;
7
8use super::{
9    FIELDS, RawSkillIr, SkillArtifactContract, SkillGovernance, SkillIdempotencyPolicy, SkillInput,
10    SkillRetryPolicy, field_value, first_value, nested_value, validate_execution_semantics,
11};
12
13pub(super) fn validate_skill_governance(
14    raw: &RawSkillIr,
15    runx: Option<&JsonObject>,
16    risk: Option<&JsonValue>,
17) -> Result<SkillGovernance, ValidationError> {
18    Ok(SkillGovernance {
19        retry: validate_retry(
20            first_value(raw.frontmatter.get("retry"), field_value(runx, "retry")),
21            "retry",
22        )?,
23        idempotency: validate_idempotency(
24            first_value(
25                raw.frontmatter.get("idempotency"),
26                field_value(runx, "idempotency"),
27            ),
28            "idempotency",
29        )?,
30        mutating: validate_mutating(
31            first_value(
32                first_value(
33                    raw.frontmatter.get("mutating"),
34                    nested_value(risk, "mutating"),
35                ),
36                field_value(runx, "mutating"),
37            ),
38            "mutating",
39        )?,
40        artifacts: validate_artifact_contract(field_value(runx, "artifacts"), "runx.artifacts")?,
41        allowed_tools: validate_allowed_tools(
42            field_value(runx, "allowed_tools"),
43            "runx.allowed_tools",
44        )?,
45        execution: validate_execution_semantics(
46            first_value(
47                raw.frontmatter.get("execution"),
48                field_value(runx, "execution"),
49            ),
50            "execution",
51        )?,
52    })
53}
54
55pub fn validate_skill_artifact_contract(
56    value: Option<&JsonValue>,
57    field: &str,
58) -> Result<Option<SkillArtifactContract>, ValidationError> {
59    validate_artifact_contract(value, field)
60}
61
62pub(super) fn validate_artifact_contract(
63    value: Option<&JsonValue>,
64    field: &str,
65) -> Result<Option<SkillArtifactContract>, ValidationError> {
66    let Some(record) = FIELDS.optional_object(value, field)? else {
67        return Ok(None);
68    };
69    let emits = match record.get("emits") {
70        Some(JsonValue::String(value)) => Some(vec![value.clone()]),
71        value => FIELDS.optional_string_array(value, &format!("{field}.emits"))?,
72    };
73    let named_emits = validate_named_emits(
74        first_value(record.get("named_emits"), record.get("namedEmits")),
75        &format!("{field}.named_emits"),
76    )?;
77    let wrap_as = FIELDS.optional_non_empty_string(
78        first_value(record.get("wrap_as"), record.get("wrapAs")),
79        &format!("{field}.wrap_as"),
80    )?;
81    if emits.is_none() && named_emits.is_none() && wrap_as.is_none() {
82        return Ok(None);
83    }
84    Ok(Some(SkillArtifactContract {
85        emits,
86        named_emits,
87        wrap_as,
88    }))
89}
90
91pub(super) fn validate_inputs(
92    inputs: JsonObject,
93) -> Result<BTreeMap<String, SkillInput>, ValidationError> {
94    inputs
95        .into_iter()
96        .map(|(name, value)| {
97            let field = format!("inputs.{name}");
98            let input = FIELDS.required_object(Some(&value), &field)?;
99            Ok((
100                name.clone(),
101                SkillInput {
102                    input_type: FIELDS
103                        .optional_string(input.get("type"), &format!("{field}.type"))?
104                        .unwrap_or_else(|| "string".to_owned()),
105                    required: FIELDS
106                        .optional_bool(input.get("required"), &format!("{field}.required"))?
107                        .unwrap_or(false),
108                    description: FIELDS.optional_string(
109                        input.get("description"),
110                        &format!("{field}.description"),
111                    )?,
112                    default: input.get("default").cloned(),
113                },
114            ))
115        })
116        .collect()
117}
118
119pub(super) fn validate_retry(
120    value: Option<&JsonValue>,
121    field: &str,
122) -> Result<Option<SkillRetryPolicy>, ValidationError> {
123    let Some(retry) = FIELDS.optional_object(value, field)? else {
124        return Ok(None);
125    };
126    let max_attempts = FIELDS
127        .optional_u64(retry.get("max_attempts"), &format!("{field}.max_attempts"))?
128        .unwrap_or(1);
129    if max_attempts == 0 {
130        return Err(
131            FIELDS.validation_error(format!("{field}.max_attempts must be a positive integer."))
132        );
133    }
134    Ok(Some(SkillRetryPolicy { max_attempts }))
135}
136
137pub(super) fn validate_idempotency(
138    value: Option<&JsonValue>,
139    field: &str,
140) -> Result<Option<SkillIdempotencyPolicy>, ValidationError> {
141    match value {
142        None | Some(JsonValue::Null) => Ok(None),
143        Some(JsonValue::String(value)) if value.trim().is_empty() => {
144            Err(FIELDS.validation_error(format!("{field} must not be empty.")))
145        }
146        Some(JsonValue::String(value)) => Ok(Some(SkillIdempotencyPolicy {
147            key: Some(value.clone()),
148        })),
149        Some(value) => {
150            let record = FIELDS.required_object(Some(value), field)?;
151            Ok(Some(SkillIdempotencyPolicy {
152                key: FIELDS
153                    .optional_non_empty_string(record.get("key"), &format!("{field}.key"))?,
154            }))
155        }
156    }
157}
158
159pub(super) fn validate_mutating(
160    value: Option<&JsonValue>,
161    field: &str,
162) -> Result<Option<bool>, ValidationError> {
163    FIELDS.optional_bool(value, field)
164}
165
166fn validate_named_emits(
167    value: Option<&JsonValue>,
168    field: &str,
169) -> Result<Option<BTreeMap<String, String>>, ValidationError> {
170    let Some(record) = FIELDS.optional_object(value, field)? else {
171        return Ok(None);
172    };
173    record
174        .into_iter()
175        .map(|(key, value)| {
176            let JsonValue::String(value) = value else {
177                return Err(
178                    FIELDS.validation_error(format!("{field}.{key} must be a non-empty string."))
179                );
180            };
181            if value.trim().is_empty() {
182                return Err(
183                    FIELDS.validation_error(format!("{field}.{key} must be a non-empty string."))
184                );
185            }
186            Ok((key, value))
187        })
188        .collect::<Result<BTreeMap<_, _>, _>>()
189        .map(Some)
190}
191
192pub(super) fn validate_allowed_tools(
193    value: Option<&JsonValue>,
194    field: &str,
195) -> Result<Option<Vec<String>>, ValidationError> {
196    let Some(values) = FIELDS.optional_string_array(value, field)? else {
197        return Ok(None);
198    };
199    for value in &values {
200        let admission = admit_agent_tool_ref(value);
201        if !admission.allowed {
202            return Err(FIELDS.validation_error(format!(
203                "{field} entry {value:?} is not an admissible agent tool ref: {}.",
204                admission.reason
205            )));
206        }
207    }
208    Ok(Some(values))
209}