1use std::collections::HashMap;
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HeartbeatConfig {
19 pub agent_id: String,
21 pub interval: Duration,
23 pub enabled: bool,
25}
26
27impl HeartbeatConfig {
28 pub fn new(agent_id: impl Into<String>, interval: Duration) -> Self {
30 Self {
31 agent_id: agent_id.into(),
32 interval,
33 enabled: true,
34 }
35 }
36}
37
38#[non_exhaustive]
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42#[derive(Default)]
43pub enum HeartbeatPhase {
44 Wake,
46 CheckQueue,
48 Execute,
50 #[default]
52 Sleep,
53 Report,
55}
56
57impl HeartbeatPhase {
58 pub fn next(self) -> Self {
60 match self {
61 Self::Wake => Self::CheckQueue,
62 Self::CheckQueue => Self::Execute,
63 Self::Execute => Self::Sleep,
64 Self::Sleep => Self::Report,
65 Self::Report => Self::Wake,
66 }
67 }
68}
69
70impl std::fmt::Display for HeartbeatPhase {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::Wake => write!(f, "wake"),
74 Self::CheckQueue => write!(f, "check_queue"),
75 Self::Execute => write!(f, "execute"),
76 Self::Sleep => write!(f, "sleep"),
77 Self::Report => write!(f, "report"),
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
84struct AgentHeartbeat {
85 config: HeartbeatConfig,
86 phase: HeartbeatPhase,
87 elapsed: Duration,
89 tick_count: u64,
91}
92
93#[derive(Debug, Clone)]
95pub struct HeartbeatTickResult {
96 pub agent_id: String,
98 pub phase: HeartbeatPhase,
100 pub tick_count: u64,
102}
103
104#[derive(Debug)]
110pub struct HeartbeatScheduler {
111 agents: HashMap<String, AgentHeartbeat>,
112}
113
114impl HeartbeatScheduler {
115 pub fn new() -> Self {
117 Self {
118 agents: HashMap::new(),
119 }
120 }
121
122 pub fn register(&mut self, config: HeartbeatConfig) {
124 let id = config.agent_id.clone();
125 self.agents.insert(
126 id,
127 AgentHeartbeat {
128 config,
129 phase: HeartbeatPhase::Sleep,
130 elapsed: Duration::ZERO,
131 tick_count: 0,
132 },
133 );
134 }
135
136 pub fn unregister(&mut self, agent_id: &str) -> bool {
138 self.agents.remove(agent_id).is_some()
139 }
140
141 pub fn tick(&mut self, delta: Duration) -> Vec<HeartbeatTickResult> {
146 let mut wakeups = Vec::new();
147
148 for hb in self.agents.values_mut() {
149 if !hb.config.enabled {
150 continue;
151 }
152
153 hb.elapsed += delta;
154
155 if hb.elapsed >= hb.config.interval {
156 hb.elapsed -= hb.config.interval;
157 hb.phase = HeartbeatPhase::Wake;
158 hb.tick_count += 1;
159
160 wakeups.push(HeartbeatTickResult {
161 agent_id: hb.config.agent_id.clone(),
162 phase: hb.phase,
163 tick_count: hb.tick_count,
164 });
165 }
166 }
167
168 wakeups
169 }
170
171 pub fn phase(&self, agent_id: &str) -> Option<HeartbeatPhase> {
173 self.agents.get(agent_id).map(|hb| hb.phase)
174 }
175
176 pub fn advance_phase(&mut self, agent_id: &str) -> Option<HeartbeatPhase> {
178 if let Some(hb) = self.agents.get_mut(agent_id) {
179 hb.phase = hb.phase.next();
180 Some(hb.phase)
181 } else {
182 None
183 }
184 }
185
186 pub fn len(&self) -> usize {
188 self.agents.len()
189 }
190
191 pub fn is_empty(&self) -> bool {
193 self.agents.is_empty()
194 }
195
196 pub fn agent_ids(&self) -> Vec<&str> {
198 self.agents.keys().map(|s| s.as_str()).collect()
199 }
200
201 pub fn tick_count(&self, agent_id: &str) -> Option<u64> {
203 self.agents.get(agent_id).map(|hb| hb.tick_count)
204 }
205}
206
207impl Default for HeartbeatScheduler {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn heartbeat_config_new() {
219 let cfg = HeartbeatConfig::new("agent-1", Duration::from_secs(10));
220 assert_eq!(cfg.agent_id, "agent-1");
221 assert_eq!(cfg.interval, Duration::from_secs(10));
222 assert!(cfg.enabled);
223 }
224
225 #[test]
226 fn heartbeat_phase_cycle() {
227 let mut phase = HeartbeatPhase::Wake;
228 phase = phase.next(); assert_eq!(phase, HeartbeatPhase::CheckQueue);
230 phase = phase.next(); assert_eq!(phase, HeartbeatPhase::Execute);
232 phase = phase.next(); assert_eq!(phase, HeartbeatPhase::Sleep);
234 phase = phase.next(); assert_eq!(phase, HeartbeatPhase::Report);
236 phase = phase.next(); assert_eq!(phase, HeartbeatPhase::Wake);
238 }
239
240 #[test]
241 fn heartbeat_phase_default_is_sleep() {
242 assert_eq!(HeartbeatPhase::default(), HeartbeatPhase::Sleep);
243 }
244
245 #[test]
246 fn heartbeat_phase_display() {
247 assert_eq!(HeartbeatPhase::Wake.to_string(), "wake");
248 assert_eq!(HeartbeatPhase::CheckQueue.to_string(), "check_queue");
249 assert_eq!(HeartbeatPhase::Execute.to_string(), "execute");
250 assert_eq!(HeartbeatPhase::Sleep.to_string(), "sleep");
251 assert_eq!(HeartbeatPhase::Report.to_string(), "report");
252 }
253
254 #[test]
255 fn heartbeat_phase_serde() {
256 let phases = [
257 (HeartbeatPhase::Wake, "\"wake\""),
258 (HeartbeatPhase::CheckQueue, "\"check_queue\""),
259 (HeartbeatPhase::Execute, "\"execute\""),
260 (HeartbeatPhase::Sleep, "\"sleep\""),
261 (HeartbeatPhase::Report, "\"report\""),
262 ];
263 for (phase, expected) in &phases {
264 let json = serde_json::to_string(phase).unwrap();
265 assert_eq!(&json, expected);
266 let restored: HeartbeatPhase = serde_json::from_str(&json).unwrap();
267 assert_eq!(&restored, phase);
268 }
269 }
270
271 #[test]
272 fn scheduler_empty() {
273 let sched = HeartbeatScheduler::new();
274 assert!(sched.is_empty());
275 assert_eq!(sched.len(), 0);
276 }
277
278 #[test]
279 fn scheduler_register_unregister() {
280 let mut sched = HeartbeatScheduler::new();
281 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(5)));
282 assert_eq!(sched.len(), 1);
283 assert!(!sched.is_empty());
284
285 assert!(sched.unregister("a1"));
286 assert!(sched.is_empty());
287 assert!(!sched.unregister("a1")); }
289
290 #[test]
291 fn scheduler_tick_no_wakeup_before_interval() {
292 let mut sched = HeartbeatScheduler::new();
293 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(10)));
294
295 let wakeups = sched.tick(Duration::from_secs(5));
296 assert!(wakeups.is_empty());
297 }
298
299 #[test]
300 fn scheduler_tick_wakeup_at_interval() {
301 let mut sched = HeartbeatScheduler::new();
302 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(10)));
303
304 let wakeups = sched.tick(Duration::from_secs(10));
305 assert_eq!(wakeups.len(), 1);
306 assert_eq!(wakeups[0].agent_id, "a1");
307 assert_eq!(wakeups[0].phase, HeartbeatPhase::Wake);
308 assert_eq!(wakeups[0].tick_count, 1);
309 }
310
311 #[test]
312 fn scheduler_tick_accumulates() {
313 let mut sched = HeartbeatScheduler::new();
314 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(10)));
315
316 let w1 = sched.tick(Duration::from_secs(4));
318 assert!(w1.is_empty());
319 let w2 = sched.tick(Duration::from_secs(4));
320 assert!(w2.is_empty());
321
322 let w3 = sched.tick(Duration::from_secs(3));
324 assert_eq!(w3.len(), 1);
325 }
326
327 #[test]
328 fn scheduler_tick_disabled_agent_skipped() {
329 let mut sched = HeartbeatScheduler::new();
330 let mut cfg = HeartbeatConfig::new("a1", Duration::from_secs(1));
331 cfg.enabled = false;
332 sched.register(cfg);
333
334 let wakeups = sched.tick(Duration::from_secs(5));
335 assert!(wakeups.is_empty());
336 }
337
338 #[test]
339 fn scheduler_tick_multiple_agents() {
340 let mut sched = HeartbeatScheduler::new();
341 sched.register(HeartbeatConfig::new("fast", Duration::from_secs(5)));
342 sched.register(HeartbeatConfig::new("slow", Duration::from_secs(15)));
343
344 let w1 = sched.tick(Duration::from_secs(5));
346 assert_eq!(w1.len(), 1);
347 assert_eq!(w1[0].agent_id, "fast");
348
349 let w2 = sched.tick(Duration::from_secs(5));
351 assert_eq!(w2.len(), 1);
352 assert_eq!(w2[0].agent_id, "fast");
353
354 let w3 = sched.tick(Duration::from_secs(5));
356 assert_eq!(w3.len(), 2);
357 }
358
359 #[test]
360 fn scheduler_phase_tracking() {
361 let mut sched = HeartbeatScheduler::new();
362 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(1)));
363
364 assert_eq!(sched.phase("a1"), Some(HeartbeatPhase::Sleep));
366
367 sched.tick(Duration::from_secs(1));
369 assert_eq!(sched.phase("a1"), Some(HeartbeatPhase::Wake));
370
371 assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::CheckQueue));
373 assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::Execute));
374 assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::Sleep));
375 assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::Report));
376 assert_eq!(sched.advance_phase("a1"), Some(HeartbeatPhase::Wake));
377 }
378
379 #[test]
380 fn scheduler_phase_unknown_agent() {
381 let sched = HeartbeatScheduler::new();
382 assert_eq!(sched.phase("unknown"), None);
383 }
384
385 #[test]
386 fn scheduler_advance_phase_unknown_agent() {
387 let mut sched = HeartbeatScheduler::new();
388 assert_eq!(sched.advance_phase("unknown"), None);
389 }
390
391 #[test]
392 fn scheduler_agent_ids() {
393 let mut sched = HeartbeatScheduler::new();
394 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(1)));
395 sched.register(HeartbeatConfig::new("a2", Duration::from_secs(2)));
396 let mut ids = sched.agent_ids();
397 ids.sort();
398 assert_eq!(ids, vec!["a1", "a2"]);
399 }
400
401 #[test]
402 fn scheduler_tick_count() {
403 let mut sched = HeartbeatScheduler::new();
404 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(1)));
405
406 assert_eq!(sched.tick_count("a1"), Some(0));
407 sched.tick(Duration::from_secs(1));
408 assert_eq!(sched.tick_count("a1"), Some(1));
409 sched.tick(Duration::from_secs(1));
410 assert_eq!(sched.tick_count("a1"), Some(2));
411 }
412
413 #[test]
414 fn scheduler_register_replaces() {
415 let mut sched = HeartbeatScheduler::new();
416 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(5)));
417 sched.tick(Duration::from_secs(5));
418 assert_eq!(sched.tick_count("a1"), Some(1));
419
420 sched.register(HeartbeatConfig::new("a1", Duration::from_secs(10)));
422 assert_eq!(sched.tick_count("a1"), Some(0));
423 assert_eq!(sched.phase("a1"), Some(HeartbeatPhase::Sleep));
424 }
425
426 #[test]
427 fn heartbeat_config_serde_roundtrip() {
428 let cfg = HeartbeatConfig::new("a1", Duration::from_secs(30));
429 let json = serde_json::to_string(&cfg).unwrap();
430 let restored: HeartbeatConfig = serde_json::from_str(&json).unwrap();
431 assert_eq!(restored.agent_id, "a1");
432 assert_eq!(restored.interval, Duration::from_secs(30));
433 assert!(restored.enabled);
434 }
435}