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