acp/
attempts.rs

1//! @acp:module "Attempt Tracking"
2//! @acp:summary "Attempt tracking and rollback system for AI troubleshooting"
3//! @acp:domain cli
4//! @acp:layer service
5//!
6//! Manages troubleshooting attempts, checkpoints, and rollbacks.
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14use crate::constraints::AttemptStatus;
15use crate::error::Result;
16
17fn default_attempts_schema() -> String {
18    "https://acp-protocol.dev/schemas/v1/attempts.schema.json".to_string()
19}
20
21/// Tracks all attempts across the project
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct AttemptTracker {
24    /// JSON Schema URL for validation
25    #[serde(rename = "$schema", default = "default_attempts_schema")]
26    pub schema: String,
27
28    /// Version
29    pub version: String,
30
31    /// When last updated
32    pub updated_at: DateTime<Utc>,
33
34    /// Active attempts by ID
35    pub attempts: HashMap<String, TrackedAttempt>,
36
37    /// Checkpoints by name
38    pub checkpoints: HashMap<String, TrackedCheckpoint>,
39
40    /// Attempt history (completed/reverted)
41    pub history: Vec<AttemptHistoryEntry>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TrackedAttempt {
46    pub id: String,
47    pub for_issue: Option<String>,
48    pub description: Option<String>,
49    pub status: AttemptStatus,
50    pub created_at: DateTime<Utc>,
51    pub updated_at: DateTime<Utc>,
52
53    /// Files modified in this attempt
54    pub files: Vec<AttemptFile>,
55
56    /// Conditions that should trigger revert
57    pub revert_if: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct AttemptFile {
62    pub path: String,
63    pub original_hash: String,
64    pub original_content: Option<String>,
65    pub modified_hash: String,
66    pub lines_changed: Option<[usize; 2]>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TrackedCheckpoint {
71    pub name: String,
72    pub created_at: DateTime<Utc>,
73    pub description: Option<String>,
74
75    /// Git commit SHA at checkpoint time
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub git_commit: Option<String>,
78
79    /// File states at checkpoint
80    pub files: HashMap<String, FileState>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct FileState {
85    pub hash: String,
86    pub content: Option<String>, // Only stored if small enough
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct AttemptHistoryEntry {
91    pub id: String,
92    pub status: AttemptStatus,
93    pub started_at: DateTime<Utc>,
94    pub ended_at: DateTime<Utc>,
95    pub for_issue: Option<String>,
96    pub files_modified: usize,
97    pub outcome: Option<String>,
98}
99
100impl AttemptTracker {
101    const FILE_NAME: &'static str = ".acp/acp.attempts.json";
102    const MAX_STORED_CONTENT_SIZE: usize = 100_000; // 100KB
103
104    /// Load or create tracker
105    pub fn load_or_create() -> Self {
106        Self::load().unwrap_or_else(|_| Self {
107            schema: default_attempts_schema(),
108            version: crate::VERSION.to_string(),
109            updated_at: Utc::now(),
110            attempts: HashMap::new(),
111            checkpoints: HashMap::new(),
112            history: Vec::new(),
113        })
114    }
115
116    /// Load from file
117    pub fn load() -> Result<Self> {
118        let content = fs::read_to_string(Self::FILE_NAME)?;
119        Ok(serde_json::from_str(&content)?)
120    }
121
122    /// Save to file
123    pub fn save(&self) -> Result<()> {
124        let content = serde_json::to_string_pretty(self)?;
125        fs::write(Self::FILE_NAME, content)?;
126        Ok(())
127    }
128
129    /// Start a new attempt
130    pub fn start_attempt(
131        &mut self,
132        id: &str,
133        for_issue: Option<&str>,
134        description: Option<&str>,
135    ) -> &mut TrackedAttempt {
136        let attempt = TrackedAttempt {
137            id: id.to_string(),
138            for_issue: for_issue.map(String::from),
139            description: description.map(String::from),
140            status: AttemptStatus::Active,
141            created_at: Utc::now(),
142            updated_at: Utc::now(),
143            files: Vec::new(),
144            revert_if: Vec::new(),
145        };
146
147        self.attempts.insert(id.to_string(), attempt);
148        self.updated_at = Utc::now();
149        self.attempts.get_mut(id).unwrap()
150    }
151
152    /// Record a file modification in an attempt
153    pub fn record_modification(
154        &mut self,
155        attempt_id: &str,
156        file_path: &str,
157        original_content: &str,
158        new_content: &str,
159    ) -> Result<()> {
160        let attempt = self.attempts.get_mut(attempt_id).ok_or_else(|| {
161            crate::error::AcpError::Other(format!("Attempt not found: {}", attempt_id))
162        })?;
163
164        let original_hash = format!("{:x}", md5::compute(original_content));
165        let modified_hash = format!("{:x}", md5::compute(new_content));
166
167        // Store original content if small enough
168        let stored_content = if original_content.len() <= Self::MAX_STORED_CONTENT_SIZE {
169            Some(original_content.to_string())
170        } else {
171            None
172        };
173
174        attempt.files.push(AttemptFile {
175            path: file_path.to_string(),
176            original_hash,
177            original_content: stored_content,
178            modified_hash,
179            lines_changed: None,
180        });
181
182        attempt.updated_at = Utc::now();
183        self.updated_at = Utc::now();
184        Ok(())
185    }
186
187    /// Mark attempt as failed
188    pub fn fail_attempt(&mut self, id: &str, reason: Option<&str>) -> Result<()> {
189        if let Some(attempt) = self.attempts.get_mut(id) {
190            attempt.status = AttemptStatus::Failed;
191            attempt.updated_at = Utc::now();
192
193            // Move to history
194            self.history.push(AttemptHistoryEntry {
195                id: attempt.id.clone(),
196                status: AttemptStatus::Failed,
197                started_at: attempt.created_at,
198                ended_at: Utc::now(),
199                for_issue: attempt.for_issue.clone(),
200                files_modified: attempt.files.len(),
201                outcome: reason.map(String::from),
202            });
203        }
204        self.updated_at = Utc::now();
205        Ok(())
206    }
207
208    /// Mark attempt as verified/successful
209    pub fn verify_attempt(&mut self, id: &str) -> Result<()> {
210        if let Some(attempt) = self.attempts.get_mut(id) {
211            attempt.status = AttemptStatus::Verified;
212            attempt.updated_at = Utc::now();
213
214            // Move to history
215            self.history.push(AttemptHistoryEntry {
216                id: attempt.id.clone(),
217                status: AttemptStatus::Verified,
218                started_at: attempt.created_at,
219                ended_at: Utc::now(),
220                for_issue: attempt.for_issue.clone(),
221                files_modified: attempt.files.len(),
222                outcome: Some("Verified and kept".to_string()),
223            });
224
225            // Remove from active attempts
226            self.attempts.remove(id);
227        }
228        self.updated_at = Utc::now();
229        Ok(())
230    }
231
232    /// Revert an attempt
233    pub fn revert_attempt(&mut self, id: &str) -> Result<Vec<RevertAction>> {
234        let attempt = self
235            .attempts
236            .get(id)
237            .ok_or_else(|| crate::error::AcpError::Other(format!("Attempt not found: {}", id)))?
238            .clone();
239
240        let mut actions = Vec::new();
241
242        for file in &attempt.files {
243            if let Some(original) = &file.original_content {
244                // Restore original content
245                fs::write(&file.path, original)?;
246                actions.push(RevertAction {
247                    file: file.path.clone(),
248                    action: "restored".to_string(),
249                    from_hash: file.modified_hash.clone(),
250                    to_hash: file.original_hash.clone(),
251                });
252            } else {
253                // Content not stored, just mark as needing manual revert
254                actions.push(RevertAction {
255                    file: file.path.clone(),
256                    action: "manual-revert-needed".to_string(),
257                    from_hash: file.modified_hash.clone(),
258                    to_hash: file.original_hash.clone(),
259                });
260            }
261        }
262
263        // Move to history
264        self.history.push(AttemptHistoryEntry {
265            id: attempt.id.clone(),
266            status: AttemptStatus::Reverted,
267            started_at: attempt.created_at,
268            ended_at: Utc::now(),
269            for_issue: attempt.for_issue.clone(),
270            files_modified: attempt.files.len(),
271            outcome: Some("Reverted".to_string()),
272        });
273
274        // Remove from active
275        self.attempts.remove(id);
276        self.updated_at = Utc::now();
277        self.save()?;
278
279        Ok(actions)
280    }
281
282    /// Create a checkpoint
283    pub fn create_checkpoint(
284        &mut self,
285        name: &str,
286        files: &[&str],
287        description: Option<&str>,
288    ) -> Result<()> {
289        let mut file_states = HashMap::new();
290        let mut file_data: Vec<(String, String, String)> = Vec::new(); // (path, content, hash)
291
292        for file_path in files {
293            if Path::new(file_path).exists() {
294                let content = fs::read_to_string(file_path)?;
295                let hash = format!("{:x}", md5::compute(&content));
296
297                let stored_content = if content.len() <= Self::MAX_STORED_CONTENT_SIZE {
298                    Some(content.clone())
299                } else {
300                    None
301                };
302
303                file_data.push((file_path.to_string(), content, hash.clone()));
304                file_states.insert(
305                    file_path.to_string(),
306                    FileState {
307                        hash,
308                        content: stored_content,
309                    },
310                );
311            }
312        }
313
314        // Capture git commit SHA if in a git repository
315        let git_commit = std::process::Command::new("git")
316            .args(["rev-parse", "HEAD"])
317            .output()
318            .ok()
319            .filter(|o| o.status.success())
320            .and_then(|o| String::from_utf8(o.stdout).ok())
321            .map(|s| s.trim().to_string())
322            .filter(|s| s.len() == 40);
323
324        self.checkpoints.insert(
325            name.to_string(),
326            TrackedCheckpoint {
327                name: name.to_string(),
328                created_at: Utc::now(),
329                description: description.map(String::from),
330                git_commit,
331                files: file_states,
332            },
333        );
334
335        // Also track these files in the most recent active attempt
336        if let Some(attempt) = self
337            .attempts
338            .values_mut()
339            .filter(|a| a.status == AttemptStatus::Active)
340            .max_by_key(|a| a.created_at)
341        {
342            for (path, content, hash) in file_data {
343                // Check if file already tracked in this attempt
344                if !attempt.files.iter().any(|f| f.path == path) {
345                    let stored_content = if content.len() <= Self::MAX_STORED_CONTENT_SIZE {
346                        Some(content)
347                    } else {
348                        None
349                    };
350                    attempt.files.push(AttemptFile {
351                        path,
352                        original_hash: hash.clone(),
353                        original_content: stored_content,
354                        modified_hash: hash, // Same as original until modified
355                        lines_changed: None,
356                    });
357                    attempt.updated_at = Utc::now();
358                }
359            }
360        }
361
362        self.updated_at = Utc::now();
363        self.save()?;
364        Ok(())
365    }
366
367    /// Restore to a checkpoint
368    pub fn restore_checkpoint(&mut self, name: &str) -> Result<Vec<RevertAction>> {
369        let checkpoint = self
370            .checkpoints
371            .get(name)
372            .ok_or_else(|| {
373                crate::error::AcpError::Other(format!("Checkpoint not found: {}", name))
374            })?
375            .clone();
376
377        let mut actions = Vec::new();
378
379        for (path, state) in &checkpoint.files {
380            if let Some(content) = &state.content {
381                fs::write(path, content)?;
382                actions.push(RevertAction {
383                    file: path.clone(),
384                    action: "restored".to_string(),
385                    from_hash: "current".to_string(),
386                    to_hash: state.hash.clone(),
387                });
388            } else {
389                actions.push(RevertAction {
390                    file: path.clone(),
391                    action: "manual-restore-needed".to_string(),
392                    from_hash: "current".to_string(),
393                    to_hash: state.hash.clone(),
394                });
395            }
396        }
397
398        self.updated_at = Utc::now();
399        Ok(actions)
400    }
401
402    /// Get all active attempts
403    pub fn active_attempts(&self) -> Vec<&TrackedAttempt> {
404        self.attempts
405            .values()
406            .filter(|a| a.status == AttemptStatus::Active || a.status == AttemptStatus::Testing)
407            .collect()
408    }
409
410    /// Get failed attempts
411    pub fn failed_attempts(&self) -> Vec<&TrackedAttempt> {
412        self.attempts
413            .values()
414            .filter(|a| a.status == AttemptStatus::Failed)
415            .collect()
416    }
417
418    /// Clean up failed attempts (revert all)
419    pub fn cleanup_failed(&mut self) -> Result<Vec<RevertAction>> {
420        let failed_ids: Vec<_> = self
421            .failed_attempts()
422            .iter()
423            .map(|a| a.id.clone())
424            .collect();
425
426        let mut all_actions = Vec::new();
427        for id in failed_ids {
428            let actions = self.revert_attempt(&id)?;
429            all_actions.extend(actions);
430        }
431
432        Ok(all_actions)
433    }
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct RevertAction {
438    pub file: String,
439    pub action: String,
440    pub from_hash: String,
441    pub to_hash: String,
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_start_attempt() {
450        let mut tracker = AttemptTracker::load_or_create();
451        tracker.start_attempt("test-001", Some("bug#123"), Some("Testing fix"));
452
453        assert!(tracker.attempts.contains_key("test-001"));
454        assert_eq!(
455            tracker.attempts["test-001"].for_issue,
456            Some("bug#123".to_string())
457        );
458    }
459}