Skip to main content

forge_pilot/
history.rs

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    /// Returns the history entry recorded for a stable target key, if any.
29    pub fn entry(&self, stable_target_key: &str) -> Option<&TargetHistoryEntry> {
30        self.entries.get(stable_target_key)
31    }
32
33    /// Returns how many attempts have been recorded for a stable target key.
34    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    /// Returns true when a stable target key has been marked exhausted.
41    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    /// Marks a stable target key as selected for another attempt.
48    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    /// Records the outcome of an attempted action for a stable target key.
66    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        // LIB-MED-002: cap history vectors to prevent unbounded growth
86        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    /// Iterates over all recorded history entries keyed by stable target key.
114    pub fn entries(&self) -> impl Iterator<Item = (&String, &TargetHistoryEntry)> {
115        self.entries.iter()
116    }
117}