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    /// §12.2 · position an already-loaded cascade where a checkpoint recorded it.
56    ///
57    /// `phase_id` is the *current* (not-yet-passed) phase; `None` means every phase passed. The
58    /// contract itself is reloaded from the operation's configuration first, so this restores the
59    /// cursor only — it can never invent a phase the configuration does not declare, and it reports
60    /// `false` rather than guessing when the id names nothing.
61    pub fn restore_cursor(&mut self, phase_id: Option<&str>, blocked_count: usize) -> bool {
62        let Some(contract) = &self.contract else {
63            return phase_id.is_none();
64        };
65        let index = match phase_id {
66            None => contract.phases.len(),
67            Some(id) => match contract.phases.iter().position(|phase| phase.id == id) {
68                Some(index) => index,
69                None => return false,
70            },
71        };
72        self.current_phase = index;
73        self.blocked_count = blocked_count;
74        true
75    }
76
77    /// How many times the current phase has been blocked — the retry budget a restore must not
78    /// hand back full.
79    pub fn blocked_count(&self) -> usize {
80        self.blocked_count
81    }
82
83    /// A phase passed: move the cursor to the next phase and reset the block counter.
84    pub fn advance(&mut self) {
85        self.current_phase += 1;
86        self.blocked_count = 0;
87    }
88
89    /// The current phase was blocked; returns the updated consecutive-block count.
90    pub fn record_block(&mut self) -> usize {
91        self.blocked_count += 1;
92        self.blocked_count
93    }
94
95    /// Returns `true` when there is no contract or all phases have passed.
96    pub fn is_complete(&self) -> bool {
97        match &self.contract {
98            None => true,
99            Some(c) => self.current_phase >= c.phases.len(),
100        }
101    }
102}
103
104impl Default for MilestoneTracker {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::types::milestone::{MilestonePhase, MilestoneRollbackPolicy};
114
115    #[test]
116    fn test_tracker_no_contract_is_complete() {
117        let tracker = MilestoneTracker::new();
118        assert!(tracker.is_complete());
119        assert_eq!(tracker.current_phase_id(), None);
120        assert!(tracker.current_criteria().is_empty());
121    }
122
123    #[test]
124    fn test_tracker_single_phase_is_incomplete_until_passed() {
125        use crate::types::milestone::MilestoneUnlockPolicy;
126        let contract = MilestoneContract {
127            phases: vec![MilestonePhase {
128                id: "phase1".to_string(),
129                criteria: vec!["c1".to_string()],
130                unlocks: vec![],
131                retry_policy: None,
132                verifier: None,
133                required_evidence: vec![],
134                unlock_policy: MilestoneUnlockPolicy::Immediate,
135                rollback_policy: MilestoneRollbackPolicy::Terminate,
136            }],
137        };
138        let mut tracker = MilestoneTracker::new();
139        tracker.load_contract(contract);
140
141        assert!(!tracker.is_complete());
142        assert_eq!(tracker.current_phase_id(), Some("phase1"));
143        assert_eq!(tracker.current_criteria(), &["c1".to_string()]);
144    }
145
146    #[test]
147    fn test_tracker_multi_phase_advances_on_pass() {
148        use crate::types::milestone::MilestoneUnlockPolicy;
149        let contract = MilestoneContract {
150            phases: vec![
151                MilestonePhase {
152                    id: "phase1".to_string(),
153                    criteria: vec!["c1".to_string()],
154                    unlocks: vec![],
155                    retry_policy: None,
156                    verifier: None,
157                    required_evidence: vec![],
158                    unlock_policy: MilestoneUnlockPolicy::Immediate,
159                    rollback_policy: MilestoneRollbackPolicy::Terminate,
160                },
161                MilestonePhase {
162                    id: "phase2".to_string(),
163                    criteria: vec!["c2".to_string()],
164                    unlocks: vec![],
165                    retry_policy: None,
166                    verifier: None,
167                    required_evidence: vec![],
168                    unlock_policy: MilestoneUnlockPolicy::Immediate,
169                    rollback_policy: MilestoneRollbackPolicy::Terminate,
170                },
171            ],
172        };
173        let mut tracker = MilestoneTracker::new();
174        tracker.load_contract(contract);
175
176        assert_eq!(tracker.current_phase_id(), Some("phase1"));
177        tracker.advance();
178        assert_eq!(tracker.current_phase_id(), Some("phase2"));
179        tracker.advance();
180        assert!(tracker.is_complete());
181        assert_eq!(tracker.current_phase_id(), None);
182    }
183}