Skip to main content

deepstrike_core/scheduler/
milestone.rs

1//! Milestone contract tracking extracted from LoopStateMachine.
2
3use crate::types::milestone::{MilestoneContract, MilestonePhase};
4
5/// Tracks milestone contract progress and owns its phase-cursor transitions;
6/// `LoopStateMachine::handle_milestone_result` drives it via `advance`/`record_block`.
7pub struct MilestoneTracker {
8    /// Optional milestone contract loaded before the run starts.
9    contract: Option<MilestoneContract>,
10    /// Index of the current (not-yet-passed) phase within `contract`.
11    current_phase: usize,
12    /// How many times the current phase has been blocked (reset on advance).
13    blocked_count: usize,
14}
15
16impl MilestoneTracker {
17    /// Create a new milestone tracker with no contract loaded.
18    pub fn new() -> Self {
19        Self {
20            contract: None,
21            current_phase: 0,
22            blocked_count: 0,
23        }
24    }
25
26    /// Load a milestone contract. Must be called before the run starts.
27    pub fn load_contract(&mut self, contract: MilestoneContract) {
28        self.contract = Some(contract);
29        self.current_phase = 0;
30        self.blocked_count = 0;
31    }
32
33    /// The full current (not-yet-passed) phase, or `None` when no contract is
34    /// loaded or all phases are complete. Callers read verifier/criteria/unlocks/
35    /// rollback_policy from here instead of re-deriving from raw indices.
36    pub fn current_phase(&self) -> Option<&MilestonePhase> {
37        self.contract
38            .as_ref()
39            .and_then(|c| c.phases.get(self.current_phase))
40    }
41
42    /// Returns the ID of the current (not-yet-passed) phase, or `None` when
43    /// no contract is loaded or all phases are complete.
44    pub fn current_phase_id(&self) -> Option<&str> {
45        self.current_phase().map(|p| p.id.as_str())
46    }
47
48    /// Returns the acceptance criteria of the current phase as a slice.
49    pub fn current_criteria(&self) -> &[String] {
50        self.current_phase()
51            .map(|p| p.criteria.as_slice())
52            .unwrap_or(&[])
53    }
54
55    /// A phase passed: move the cursor to the next phase and reset the block counter.
56    pub fn advance(&mut self) {
57        self.current_phase += 1;
58        self.blocked_count = 0;
59    }
60
61    /// The current phase was blocked; returns the updated consecutive-block count.
62    pub fn record_block(&mut self) -> usize {
63        self.blocked_count += 1;
64        self.blocked_count
65    }
66
67    /// Returns `true` when there is no contract or all phases have passed.
68    pub fn is_complete(&self) -> bool {
69        match &self.contract {
70            None => true,
71            Some(c) => self.current_phase >= c.phases.len(),
72        }
73    }
74}
75
76impl Default for MilestoneTracker {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::types::milestone::{MilestonePhase, MilestoneRollbackPolicy};
86
87    #[test]
88    fn test_tracker_no_contract_is_complete() {
89        let tracker = MilestoneTracker::new();
90        assert!(tracker.is_complete());
91        assert_eq!(tracker.current_phase_id(), None);
92        assert!(tracker.current_criteria().is_empty());
93    }
94
95    #[test]
96    fn test_tracker_single_phase_is_incomplete_until_passed() {
97        use crate::types::milestone::MilestoneUnlockPolicy;
98        let contract = MilestoneContract {
99            phases: vec![MilestonePhase {
100                id: "phase1".to_string(),
101                criteria: vec!["c1".to_string()],
102                unlocks: vec![],
103                retry_policy: None,
104                verifier: None,
105                required_evidence: vec![],
106                unlock_policy: MilestoneUnlockPolicy::Immediate,
107                rollback_policy: MilestoneRollbackPolicy::Terminate,
108            }],
109        };
110        let mut tracker = MilestoneTracker::new();
111        tracker.load_contract(contract);
112
113        assert!(!tracker.is_complete());
114        assert_eq!(tracker.current_phase_id(), Some("phase1"));
115        assert_eq!(tracker.current_criteria(), &["c1".to_string()]);
116    }
117
118    #[test]
119    fn test_tracker_multi_phase_advances_on_pass() {
120        use crate::types::milestone::MilestoneUnlockPolicy;
121        let contract = MilestoneContract {
122            phases: vec![
123                MilestonePhase {
124                    id: "phase1".to_string(),
125                    criteria: vec!["c1".to_string()],
126                    unlocks: vec![],
127                    retry_policy: None,
128                    verifier: None,
129                    required_evidence: vec![],
130                    unlock_policy: MilestoneUnlockPolicy::Immediate,
131                    rollback_policy: MilestoneRollbackPolicy::Terminate,
132                },
133                MilestonePhase {
134                    id: "phase2".to_string(),
135                    criteria: vec!["c2".to_string()],
136                    unlocks: vec![],
137                    retry_policy: None,
138                    verifier: None,
139                    required_evidence: vec![],
140                    unlock_policy: MilestoneUnlockPolicy::Immediate,
141                    rollback_policy: MilestoneRollbackPolicy::Terminate,
142                },
143            ],
144        };
145        let mut tracker = MilestoneTracker::new();
146        tracker.load_contract(contract);
147
148        assert_eq!(tracker.current_phase_id(), Some("phase1"));
149        tracker.advance();
150        assert_eq!(tracker.current_phase_id(), Some("phase2"));
151        tracker.advance();
152        assert!(tracker.is_complete());
153        assert_eq!(tracker.current_phase_id(), None);
154    }
155}