Skip to main content

lean_ctx/core/gotcha_tracker/
model.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5
6/// Default for `[memory.gotcha] max_gotchas_per_project`.
7pub(super) const MAX_GOTCHAS: usize = 100;
8pub(super) const MAX_SESSION_LOGS: usize = 20;
9pub(super) const MAX_PENDING: usize = 10;
10pub(super) const PENDING_TIMEOUT_SECS: i64 = 900; // 15 minutes
11pub(super) const DECAY_ARCHIVE_THRESHOLD: f32 = 0.15;
12const PROMOTION_CONFIDENCE: f32 = 0.9;
13const PROMOTION_OCCURRENCES: u32 = 5;
14const PROMOTION_SESSIONS: usize = 3;
15const PROMOTION_PREVENTED: u32 = 2;
16
17// ---------------------------------------------------------------------------
18// Core types
19// ---------------------------------------------------------------------------
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub enum GotchaCategory {
23    Build,
24    Test,
25    Config,
26    Runtime,
27    Dependency,
28    Platform,
29    Convention,
30    Security,
31}
32
33impl GotchaCategory {
34    pub fn from_str_loose(s: &str) -> Self {
35        match s.to_lowercase().as_str() {
36            "build" | "compile" => Self::Build,
37            "test" => Self::Test,
38            "config" | "configuration" => Self::Config,
39            "runtime" => Self::Runtime,
40            "dependency" | "dep" | "deps" => Self::Dependency,
41            "platform" | "os" => Self::Platform,
42            "security" | "sec" => Self::Security,
43            _ => Self::Convention,
44        }
45    }
46
47    pub fn short_label(&self) -> &'static str {
48        match self {
49            Self::Build => "build",
50            Self::Test => "test",
51            Self::Config => "config",
52            Self::Runtime => "runtime",
53            Self::Dependency => "dep",
54            Self::Platform => "platform",
55            Self::Convention => "conv",
56            Self::Security => "sec",
57        }
58    }
59}
60
61impl std::fmt::Display for GotchaCategory {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.write_str(self.short_label())
64    }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
68pub enum GotchaSeverity {
69    Critical,
70    Warning,
71    Info,
72}
73
74impl GotchaSeverity {
75    pub fn multiplier(&self) -> f32 {
76        match self {
77            Self::Critical => 1.5,
78            Self::Warning => 1.0,
79            Self::Info => 0.7,
80        }
81    }
82
83    pub fn prefix(&self) -> &'static str {
84        match self {
85            Self::Critical | Self::Warning => "!",
86            Self::Info => "",
87        }
88    }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum GotchaSource {
93    AutoDetected { command: String, exit_code: i32 },
94    AgentReported { session_id: String },
95    CrossSessionCorrelated { sessions: Vec<String> },
96    Promoted { from_knowledge_key: String },
97}
98
99impl GotchaSource {
100    pub fn decay_rate(&self) -> f32 {
101        match self {
102            Self::Promoted { .. } => 0.01,
103            Self::AgentReported { .. } => 0.02,
104            Self::CrossSessionCorrelated { .. } => 0.03,
105            Self::AutoDetected { .. } => 0.05,
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ProvenanceRef {
112    pub kind: String,
113    pub url: Option<String>,
114    pub commit_hash: Option<String>,
115    pub tool_call_id: Option<String>,
116    pub session_id: Option<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct Gotcha {
121    pub id: String,
122    pub category: GotchaCategory,
123    pub severity: GotchaSeverity,
124    pub trigger: String,
125    pub resolution: String,
126    pub file_patterns: Vec<String>,
127    pub occurrences: u32,
128    pub session_ids: Vec<String>,
129    pub first_seen: DateTime<Utc>,
130    pub last_seen: DateTime<Utc>,
131    pub confidence: f32,
132    pub source: GotchaSource,
133    pub prevented_count: u32,
134    pub tags: Vec<String>,
135    #[serde(default)]
136    pub provenance: Vec<ProvenanceRef>,
137    #[serde(default)]
138    pub expires_at: Option<DateTime<Utc>>,
139    #[serde(default)]
140    pub decay_rate_override: Option<f32>,
141}
142
143impl Gotcha {
144    pub fn new(
145        category: GotchaCategory,
146        severity: GotchaSeverity,
147        trigger: &str,
148        resolution: &str,
149        source: GotchaSource,
150        session_id: &str,
151    ) -> Self {
152        let now = Utc::now();
153        let confidence = match &source {
154            GotchaSource::AgentReported { .. } => 0.9,
155            GotchaSource::CrossSessionCorrelated { .. } => 0.85,
156            GotchaSource::AutoDetected { .. } => 0.6,
157            GotchaSource::Promoted { .. } => 0.95,
158        };
159        Self {
160            id: gotcha_id(trigger, &category),
161            category,
162            severity,
163            trigger: trigger.to_string(),
164            resolution: resolution.to_string(),
165            file_patterns: Vec::new(),
166            occurrences: 1,
167            session_ids: vec![session_id.to_string()],
168            first_seen: now,
169            last_seen: now,
170            confidence,
171            source,
172            prevented_count: 0,
173            tags: Vec::new(),
174            provenance: Vec::new(),
175            expires_at: None,
176            decay_rate_override: None,
177        }
178    }
179
180    pub fn merge_with(&mut self, other: &Gotcha) {
181        self.occurrences += other.occurrences;
182        for sid in &other.session_ids {
183            if !self.session_ids.contains(sid) {
184                self.session_ids.push(sid.clone());
185            }
186        }
187        for fp in &other.file_patterns {
188            if !self.file_patterns.contains(fp) {
189                self.file_patterns.push(fp.clone());
190            }
191        }
192        if other.last_seen > self.last_seen {
193            self.last_seen = other.last_seen;
194            self.resolution.clone_from(&other.resolution);
195        }
196        self.confidence = self.confidence.max(other.confidence);
197    }
198
199    pub fn is_promotable(&self) -> bool {
200        self.confidence >= PROMOTION_CONFIDENCE
201            && self.occurrences >= PROMOTION_OCCURRENCES
202            && self.session_ids.len() >= PROMOTION_SESSIONS
203            && self.prevented_count >= PROMOTION_PREVENTED
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Pending errors (in-memory, not persisted)
209// ---------------------------------------------------------------------------
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct PendingError {
213    pub error_signature: String,
214    pub category: GotchaCategory,
215    pub severity: GotchaSeverity,
216    pub command: String,
217    pub exit_code: i32,
218    pub files_at_error: Vec<String>,
219    pub timestamp: DateTime<Utc>,
220    pub raw_snippet: String,
221    pub session_id: String,
222}
223
224impl PendingError {
225    pub fn is_expired(&self) -> bool {
226        (Utc::now() - self.timestamp).num_seconds() > PENDING_TIMEOUT_SECS
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Session error log (for cross-session correlation)
232// ---------------------------------------------------------------------------
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct SessionErrorLog {
236    pub session_id: String,
237    pub timestamp: DateTime<Utc>,
238    pub errors: Vec<ErrorEntry>,
239    pub fixes: Vec<FixEntry>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ErrorEntry {
244    pub signature: String,
245    pub command: String,
246    pub timestamp: DateTime<Utc>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct FixEntry {
251    pub error_signature: String,
252    pub resolution: String,
253    pub files_changed: Vec<String>,
254    pub timestamp: DateTime<Utc>,
255}
256
257// ---------------------------------------------------------------------------
258// Stats
259// ---------------------------------------------------------------------------
260
261#[derive(Debug, Clone, Serialize, Deserialize, Default)]
262pub struct GotchaStats {
263    pub total_errors_detected: u64,
264    pub total_fixes_correlated: u64,
265    pub total_prevented: u64,
266    pub gotchas_promoted: u64,
267    pub gotchas_decayed: u64,
268}
269
270// ---------------------------------------------------------------------------
271// GotchaStore
272// ---------------------------------------------------------------------------
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct GotchaStore {
276    pub project_hash: String,
277    pub gotchas: Vec<Gotcha>,
278    #[serde(default)]
279    pub error_log: Vec<SessionErrorLog>,
280    #[serde(default)]
281    pub stats: GotchaStats,
282    pub updated_at: DateTime<Utc>,
283
284    // Persisted (#451 shell-ding): a fail→fix spanning two `lean-ctx -c`
285    // invocations is two separate processes, so the in-memory active store
286    // cannot bridge them. Persisting pending errors lets the later process load
287    // and correlate the fix. Bounded by MAX_PENDING + a 15-min TTL (pruned on
288    // load and on every detect/resolve), so the file stays tiny. `default`
289    // keeps older gotchas.json files (written without the field) loadable.
290    #[serde(default)]
291    pub pending_errors: Vec<PendingError>,
292
293    /// Most gotchas kept (`[memory.gotcha] max_gotchas_per_project`), set when
294    /// the store is loaded; not persisted.
295    #[serde(skip, default = "default_capacity")]
296    pub(super) capacity: usize,
297}
298
299fn default_capacity() -> usize {
300    MAX_GOTCHAS
301}
302
303impl GotchaStore {
304    pub fn new(project_hash: &str) -> Self {
305        Self {
306            project_hash: project_hash.to_string(),
307            gotchas: Vec::new(),
308            error_log: Vec::new(),
309            stats: GotchaStats::default(),
310            updated_at: Utc::now(),
311            pending_errors: Vec::new(),
312            capacity: MAX_GOTCHAS,
313        }
314    }
315
316    pub fn clear(&mut self) {
317        self.gotchas.clear();
318        self.pending_errors.clear();
319        self.updated_at = Utc::now();
320    }
321}
322
323// ---------------------------------------------------------------------------
324// Helpers
325// ---------------------------------------------------------------------------
326
327pub(super) fn gotcha_id(trigger: &str, category: &GotchaCategory) -> String {
328    let mut hasher = DefaultHasher::new();
329    trigger.hash(&mut hasher);
330    category.short_label().hash(&mut hasher);
331    format!("{:016x}", hasher.finish())
332}