Skip to main content

orchestrator_config/config/
invariant.rs

1use serde::{Deserialize, Serialize};
2
3/// Configuration for a single invariant constraint (WP04).
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5pub struct InvariantConfig {
6    /// Stable identifier for the invariant.
7    pub name: String,
8    /// Human-readable description shown in diagnostics.
9    #[serde(default)]
10    pub description: String,
11    /// Optional shell command executed to evaluate the invariant.
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub command: Option<String>,
14    /// Expected exit code for `command`, when command-based evaluation is used.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub expect_exit: Option<i32>,
17    /// Optional pipeline variable name used to capture command output.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub capture_as: Option<String>,
20    /// CEL-style assertion evaluated against the captured result.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub assert_expr: Option<String>,
23    /// Marks the invariant as immutable once the task starts.
24    #[serde(default)]
25    pub immutable: bool,
26    /// Task checkpoints where the invariant should run.
27    #[serde(default = "default_check_at")]
28    pub check_at: Vec<InvariantCheckPoint>,
29    /// Policy to apply when the invariant fails.
30    #[serde(default)]
31    pub on_violation: OnViolation,
32    /// Files that must remain unchanged while the invariant is active.
33    #[serde(default)]
34    pub protected_files: Vec<String>,
35}
36
37fn default_check_at() -> Vec<InvariantCheckPoint> {
38    vec![
39        InvariantCheckPoint::AfterImplement,
40        InvariantCheckPoint::BeforeComplete,
41    ]
42}
43
44/// When an invariant should be checked during cycle execution.
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
46#[serde(rename_all = "snake_case")]
47pub enum InvariantCheckPoint {
48    /// Run before a workflow cycle begins.
49    BeforeCycle,
50    /// Run after the implement/fix phase completes.
51    AfterImplement,
52    /// Run before a task is restarted.
53    BeforeRestart,
54    /// Run before the task is marked complete.
55    BeforeComplete,
56}
57
58/// What to do when an invariant is violated.
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
60#[serde(rename_all = "snake_case")]
61pub enum OnViolation {
62    /// Stop execution immediately.
63    #[default]
64    Halt,
65    /// Roll back the task item before continuing.
66    Rollback,
67    /// Record a warning but keep the workflow running.
68    Warn,
69}
70
71/// Result of evaluating a single invariant.
72#[derive(Debug, Clone)]
73pub struct InvariantResult {
74    /// Name of the invariant that ran.
75    pub name: String,
76    /// Whether the invariant passed.
77    pub passed: bool,
78    /// Human-readable evaluation summary.
79    pub message: String,
80    /// Violation policy attached to the invariant.
81    pub on_violation: OnViolation,
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn test_invariant_config_defaults() {
90        let json = r#"{"name": "test", "description": "a test"}"#;
91        let cfg: InvariantConfig =
92            serde_json::from_str(json).expect("deserialize invariant config");
93        assert_eq!(cfg.name, "test");
94        assert!(!cfg.immutable);
95        assert_eq!(cfg.check_at.len(), 2);
96        assert!(cfg.check_at.contains(&InvariantCheckPoint::AfterImplement));
97        assert!(cfg.check_at.contains(&InvariantCheckPoint::BeforeComplete));
98        assert_eq!(cfg.on_violation, OnViolation::Halt);
99        assert!(cfg.protected_files.is_empty());
100    }
101
102    #[test]
103    fn test_invariant_config_full() {
104        let json = r#"{
105            "name": "no_unsafe",
106            "description": "No unsafe code",
107            "command": "grep -r unsafe src/",
108            "expect_exit": 1,
109            "assert_expr": "exit_code == 1",
110            "immutable": true,
111            "check_at": ["before_cycle", "after_implement"],
112            "on_violation": "rollback",
113            "protected_files": ["Cargo.toml", "src/main.rs"]
114        }"#;
115        let cfg: InvariantConfig =
116            serde_json::from_str(json).expect("deserialize full invariant config");
117        assert!(cfg.immutable);
118        assert_eq!(cfg.on_violation, OnViolation::Rollback);
119        assert_eq!(cfg.protected_files.len(), 2);
120        assert_eq!(cfg.check_at.len(), 2);
121    }
122
123    #[test]
124    fn test_on_violation_default() {
125        let v = OnViolation::default();
126        assert_eq!(v, OnViolation::Halt);
127    }
128
129    #[test]
130    fn test_checkpoint_serde_round_trip() {
131        for s in &[
132            "\"before_cycle\"",
133            "\"after_implement\"",
134            "\"before_restart\"",
135            "\"before_complete\"",
136        ] {
137            let cp: InvariantCheckPoint = serde_json::from_str(s).expect("deserialize checkpoint");
138            let json = serde_json::to_string(&cp).expect("serialize checkpoint");
139            assert_eq!(&json, s);
140        }
141    }
142
143    #[test]
144    fn test_on_violation_serde_round_trip() {
145        for s in &["\"halt\"", "\"rollback\"", "\"warn\""] {
146            let ov: OnViolation = serde_json::from_str(s).expect("deserialize on_violation");
147            let json = serde_json::to_string(&ov).expect("serialize on_violation");
148            assert_eq!(&json, s);
149        }
150    }
151}