1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct TargetAttemptRecord {
7 pub bundle_id: Option<String>,
8 pub selected_at: String,
9 pub outcome_signature: String,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TargetHistoryEntry {
14 pub stable_target_key: String,
15 pub prior_attempt_bundle_ids: Vec<String>,
16 pub prior_outcomes: Vec<String>,
17 pub last_selected_at: Option<String>,
18 pub retry_count: u32,
19 pub exhausted: bool,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct PilotHistory {
24 entries: BTreeMap<String, TargetHistoryEntry>,
25}
26
27impl PilotHistory {
28 pub fn entry(&self, stable_target_key: &str) -> Option<&TargetHistoryEntry> {
30 self.entries.get(stable_target_key)
31 }
32
33 pub fn retry_count(&self, stable_target_key: &str) -> u32 {
35 self.entry(stable_target_key)
36 .map(|entry| entry.retry_count)
37 .unwrap_or(0)
38 }
39
40 pub fn is_exhausted(&self, stable_target_key: &str) -> bool {
42 self.entry(stable_target_key)
43 .map(|entry| entry.exhausted)
44 .unwrap_or(false)
45 }
46
47 pub fn mark_selected(&mut self, stable_target_key: &str) {
49 let now = Utc::now().to_rfc3339();
50 let entry = self
51 .entries
52 .entry(stable_target_key.to_string())
53 .or_insert_with(|| TargetHistoryEntry {
54 stable_target_key: stable_target_key.to_string(),
55 prior_attempt_bundle_ids: Vec::new(),
56 prior_outcomes: Vec::new(),
57 last_selected_at: None,
58 retry_count: 0,
59 exhausted: false,
60 });
61 entry.retry_count += 1;
62 entry.last_selected_at = Some(now);
63 }
64
65 pub fn record_outcome(
67 &mut self,
68 stable_target_key: &str,
69 bundle_id: Option<String>,
70 outcome_signature: String,
71 max_retries_per_target: u32,
72 ) {
73 let entry = self
74 .entries
75 .entry(stable_target_key.to_string())
76 .or_insert_with(|| TargetHistoryEntry {
77 stable_target_key: stable_target_key.to_string(),
78 prior_attempt_bundle_ids: Vec::new(),
79 prior_outcomes: Vec::new(),
80 last_selected_at: None,
81 retry_count: 0,
82 exhausted: false,
83 });
84
85 const MAX_HISTORY_ENTRIES: usize = 20;
87
88 if let Some(bundle_id) = bundle_id {
89 entry.prior_attempt_bundle_ids.push(bundle_id);
90 }
91 if entry.prior_attempt_bundle_ids.len() > MAX_HISTORY_ENTRIES {
92 entry
93 .prior_attempt_bundle_ids
94 .drain(..entry.prior_attempt_bundle_ids.len() - MAX_HISTORY_ENTRIES);
95 }
96
97 let repeated = entry
98 .prior_outcomes
99 .last()
100 .map(|last| last == &outcome_signature)
101 .unwrap_or(false);
102 entry.prior_outcomes.push(outcome_signature);
103 if entry.prior_outcomes.len() > MAX_HISTORY_ENTRIES {
104 entry
105 .prior_outcomes
106 .drain(..entry.prior_outcomes.len() - MAX_HISTORY_ENTRIES);
107 }
108 if entry.retry_count >= max_retries_per_target || repeated {
109 entry.exhausted = true;
110 }
111 }
112
113 pub fn entries(&self) -> impl Iterator<Item = (&String, &TargetHistoryEntry)> {
115 self.entries.iter()
116 }
117}