Skip to main content

lean_ctx/core/
autonomy_drivers.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4const STORE_FILENAME: &str = "autonomy_drivers_v1.json";
5
6// Hard bounds: autonomy reports are observability artifacts.
7const MAX_EVENTS: usize = 128;
8const MAX_DECISIONS_PER_EVENT: usize = 16;
9const MAX_TOOL_CHARS: usize = 64;
10const MAX_ACTION_CHARS: usize = 64;
11const MAX_REASON_CODE_CHARS: usize = 48;
12const MAX_REASON_CHARS: usize = 220;
13const MAX_DETAIL_CHARS: usize = 512;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AutonomyPhaseV1 {
18    PreCall,
19    PostRead,
20    PostCall,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum AutonomyDriverKindV1 {
26    Preload,
27    Prefetch,
28    Dedup,
29    Response,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum AutonomyVerdictV1 {
35    Run,
36    Skip,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct AutonomyDriverDecisionV1 {
41    pub driver: AutonomyDriverKindV1,
42    pub verdict: AutonomyVerdictV1,
43    pub reason_code: String,
44    pub reason: String,
45    #[serde(default)]
46    pub detail: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct AutonomyDriverEventV1 {
51    pub seq: u64,
52    pub created_at: String,
53    pub phase: AutonomyPhaseV1,
54    pub role: String,
55    pub profile: String,
56    pub tool: String,
57    #[serde(default)]
58    pub action: Option<String>,
59    pub decisions: Vec<AutonomyDriverDecisionV1>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AutonomyDriversV1 {
64    pub schema_version: u32,
65    pub created_at: String,
66    pub updated_at: String,
67    pub next_seq: u64,
68    #[serde(default)]
69    pub events: Vec<AutonomyDriverEventV1>,
70}
71
72impl Default for AutonomyDriversV1 {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78fn store_path() -> Option<PathBuf> {
79    crate::core::data_dir::lean_ctx_data_dir()
80        .ok()
81        .map(|d| d.join(STORE_FILENAME))
82}
83
84impl AutonomyDriversV1 {
85    pub fn new() -> Self {
86        let now = chrono::Utc::now().to_rfc3339();
87        Self {
88            schema_version: crate::core::contracts::AUTONOMY_DRIVERS_V1_SCHEMA_VERSION,
89            created_at: now.clone(),
90            updated_at: now,
91            next_seq: 1,
92            events: Vec::new(),
93        }
94    }
95
96    pub fn load() -> Self {
97        let Some(path) = store_path() else {
98            return Self::new();
99        };
100        let content = std::fs::read_to_string(&path).unwrap_or_default();
101        serde_json::from_str::<Self>(&content).unwrap_or_else(|_| Self::new())
102    }
103
104    pub fn save(&self) -> Result<(), String> {
105        let Some(path) = store_path() else {
106            return Err("no data dir".to_string());
107        };
108        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
109        // Reports may end up in CI logs; always redact (even for admin).
110        let json = crate::core::redaction::redact_text(&json);
111        crate::config_io::write_atomic(&path, &json)?;
112        Ok(())
113    }
114
115    pub fn record(&mut self, mut ev: AutonomyDriverEventV1) {
116        ev.seq = self.next_seq;
117        self.next_seq = self.next_seq.saturating_add(1);
118        self.updated_at = chrono::Utc::now().to_rfc3339();
119
120        bound_event_in_place(&mut ev);
121        self.events.push(ev);
122        self.prune_in_place();
123    }
124
125    pub fn latest(&self) -> Option<&AutonomyDriverEventV1> {
126        self.events.last()
127    }
128
129    fn prune_in_place(&mut self) {
130        if self.events.len() <= MAX_EVENTS {
131            return;
132        }
133        let overflow = self.events.len() - MAX_EVENTS;
134        self.events.drain(0..overflow);
135    }
136}
137
138fn bound_event_in_place(ev: &mut AutonomyDriverEventV1) {
139    ev.tool = truncate(&ev.tool, MAX_TOOL_CHARS);
140    if let Some(a) = ev.action.clone() {
141        let t = truncate(&a, MAX_ACTION_CHARS);
142        ev.action = if t.trim().is_empty() { None } else { Some(t) };
143    }
144    if ev.decisions.len() > MAX_DECISIONS_PER_EVENT {
145        ev.decisions.truncate(MAX_DECISIONS_PER_EVENT);
146    }
147    for d in &mut ev.decisions {
148        d.reason_code = truncate(&d.reason_code, MAX_REASON_CODE_CHARS);
149        d.reason = truncate(&d.reason, MAX_REASON_CHARS);
150        if let Some(detail) = d.detail.clone() {
151            let t = truncate(&detail, MAX_DETAIL_CHARS);
152            d.detail = if t.trim().is_empty() { None } else { Some(t) };
153        }
154    }
155}
156
157fn truncate(s: &str, max: usize) -> String {
158    let s = s.trim();
159    if s.len() <= max {
160        return s.to_string();
161    }
162    let mut out = s[..max].to_string();
163    out.push('…');
164    out
165}
166
167pub fn write_project_autonomy_drivers_v1(
168    project_root: &Path,
169    drivers: &AutonomyDriversV1,
170    filename: Option<&str>,
171) -> Result<PathBuf, String> {
172    let proofs_dir = project_root.join(".lean-ctx").join("proofs");
173    std::fs::create_dir_all(&proofs_dir).map_err(|e| e.to_string())?;
174
175    let ts = chrono::Utc::now().format("%Y-%m-%d_%H%M%S");
176    let name = filename.map_or_else(
177        || format!("autonomy-drivers-v1_{ts}.json"),
178        std::string::ToString::to_string,
179    );
180    let path = proofs_dir.join(name);
181
182    let json = serde_json::to_string_pretty(drivers).map_err(|e| e.to_string())?;
183    let json = crate::core::redaction::redact_text(&json);
184    crate::config_io::write_atomic(&path, &json)?;
185    Ok(path)
186}
187
188pub fn format_compact_event(ev: &AutonomyDriverEventV1) -> String {
189    let mut parts = Vec::new();
190    for d in &ev.decisions {
191        let driver = match d.driver {
192            AutonomyDriverKindV1::Preload => "preload",
193            AutonomyDriverKindV1::Prefetch => "prefetch",
194            AutonomyDriverKindV1::Dedup => "dedup",
195            AutonomyDriverKindV1::Response => "response",
196        };
197        let verdict = match d.verdict {
198            AutonomyVerdictV1::Run => "run",
199            AutonomyVerdictV1::Skip => "skip",
200        };
201        parts.push(format!("{driver}={verdict}({})", d.reason_code));
202    }
203    format!(
204        "[autonomy:{}] {}",
205        match ev.phase {
206            AutonomyPhaseV1::PreCall => "pre",
207            AutonomyPhaseV1::PostRead => "read",
208            AutonomyPhaseV1::PostCall => "post",
209        },
210        parts.join(", ")
211    )
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn record_bounded_and_seq_increments() {
220        let mut s = AutonomyDriversV1::new();
221        for i in 0..(MAX_EVENTS + 5) {
222            s.record(AutonomyDriverEventV1 {
223                seq: 0,
224                created_at: "2026-01-01T00:00:00Z".to_string(),
225                phase: AutonomyPhaseV1::PreCall,
226                role: "coder".to_string(),
227                profile: "exploration".to_string(),
228                tool: format!("tool{i}"),
229                action: None,
230                decisions: vec![AutonomyDriverDecisionV1 {
231                    driver: AutonomyDriverKindV1::Preload,
232                    verdict: AutonomyVerdictV1::Skip,
233                    reason_code: "disabled".to_string(),
234                    reason: "disabled".to_string(),
235                    detail: None,
236                }],
237            });
238        }
239        assert!(s.events.len() <= MAX_EVENTS);
240        assert_eq!(s.events.last().unwrap().seq, s.next_seq - 1);
241    }
242
243    #[test]
244    fn compact_format_includes_phase_and_drivers() {
245        let ev = AutonomyDriverEventV1 {
246            seq: 1,
247            created_at: "2026-01-01T00:00:00Z".to_string(),
248            phase: AutonomyPhaseV1::PostCall,
249            role: "coder".to_string(),
250            profile: "exploration".to_string(),
251            tool: "ctx_read".to_string(),
252            action: Some("full".to_string()),
253            decisions: vec![AutonomyDriverDecisionV1 {
254                driver: AutonomyDriverKindV1::Response,
255                verdict: AutonomyVerdictV1::Run,
256                reason_code: "output_large".to_string(),
257                reason: "output large".to_string(),
258                detail: None,
259            }],
260        };
261        let s = format_compact_event(&ev);
262        assert!(s.contains("autonomy:post"));
263        assert!(s.contains("response=run"));
264    }
265}