diff options
Diffstat (limited to 'makima/src/daemon/task/context_recovery.rs')
| -rw-r--r-- | makima/src/daemon/task/context_recovery.rs | 554 |
1 files changed, 554 insertions, 0 deletions
diff --git a/makima/src/daemon/task/context_recovery.rs b/makima/src/daemon/task/context_recovery.rs new file mode 100644 index 0000000..8f1cafc --- /dev/null +++ b/makima/src/daemon/task/context_recovery.rs @@ -0,0 +1,554 @@ +//! Context recovery for task resumption and restart. +//! +//! This module provides functionality to rebuild context when tasks resume or restart, +//! ensuring Claude can quickly orient itself with the current state of the worktree. + +use std::path::Path; +use std::process::Command; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Summary of git status information. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GitStatusSummary { + /// Number of staged files. + pub staged: u32, + /// Number of modified (unstaged) files. + pub modified: u32, + /// Number of untracked files. + pub untracked: u32, + /// Number of deleted files. + pub deleted: u32, + /// Brief description (e.g., "3 modified, 1 staged"). + pub summary: String, +} + +/// Information about a checkpoint (git commit). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckpointInfo { + /// Commit SHA (short form). + pub sha: String, + /// Commit message (first line). + pub message: String, + /// Commit timestamp. + pub timestamp: DateTime<Utc>, +} + +/// A single progress entry from the progress log. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgressEntry { + /// Timestamp of the entry. + pub timestamp: DateTime<Utc>, + /// Entry type (e.g., "ITERATION", "TASK_START", "CHECKPOINT"). + pub entry_type: String, + /// Entry message or summary. + pub message: String, +} + +/// Context recovery data for a task. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextRecovery { + /// Current git branch name. + pub current_branch: String, + /// Git status summary. + pub git_status: GitStatusSummary, + /// Last checkpoint information (most recent commit). + pub last_checkpoint: Option<CheckpointInfo>, + /// Recent progress entries from progress.log. + pub recent_progress: Vec<ProgressEntry>, + /// Current phase of the task. + pub current_phase: String, +} + +impl ContextRecovery { + /// Format the context recovery as a markdown header suitable for injection into prompts. + pub fn to_markdown(&self) -> String { + let mut output = String::new(); + output.push_str("## Context Recovery\n"); + output.push_str(&format!("- Current branch: {}\n", self.current_branch)); + output.push_str(&format!("- Git status: {}\n", self.git_status.summary)); + + if let Some(ref checkpoint) = self.last_checkpoint { + output.push_str(&format!( + "- Last checkpoint: {} - {} ({})\n", + checkpoint.sha, + checkpoint.message, + checkpoint.timestamp.format("%Y-%m-%d %H:%M:%S UTC") + )); + } else { + output.push_str("- Last checkpoint: none\n"); + } + + if self.recent_progress.is_empty() { + output.push_str("- Progress log (recent): none\n"); + } else { + output.push_str("- Progress log (recent):\n"); + for entry in &self.recent_progress { + output.push_str(&format!( + " - [{}] {}: {}\n", + entry.timestamp.format("%H:%M:%S"), + entry.entry_type, + entry.message + )); + } + } + + output.push_str(&format!("- Current phase: {}\n", self.current_phase)); + output.push_str("\n---\n\n"); + + output + } +} + +/// Build context recovery information for a task worktree. +/// +/// # Arguments +/// * `worktree_path` - Path to the task's worktree directory +/// * `phase` - Current task phase (e.g., "execute", "review") +/// * `max_progress_entries` - Maximum number of recent progress entries to include +/// +/// # Returns +/// A formatted markdown string containing context recovery information. +pub fn build_context_recovery( + worktree_path: &Path, + phase: &str, + max_progress_entries: usize, +) -> String { + let context = build_context_recovery_data(worktree_path, phase, max_progress_entries); + context.to_markdown() +} + +/// Build context recovery data structure for a task worktree. +/// +/// # Arguments +/// * `worktree_path` - Path to the task's worktree directory +/// * `phase` - Current task phase (e.g., "execute", "review") +/// * `max_progress_entries` - Maximum number of recent progress entries to include +/// +/// # Returns +/// A ContextRecovery struct containing all gathered information. +pub fn build_context_recovery_data( + worktree_path: &Path, + phase: &str, + max_progress_entries: usize, +) -> ContextRecovery { + let current_branch = get_current_branch(worktree_path).unwrap_or_else(|| "unknown".to_string()); + let git_status = get_git_status_summary(worktree_path); + let last_checkpoint = get_last_checkpoint(worktree_path); + let recent_progress = get_recent_progress(worktree_path, max_progress_entries); + + ContextRecovery { + current_branch, + git_status, + last_checkpoint, + recent_progress, + current_phase: phase.to_string(), + } +} + +/// Get the current git branch name. +fn get_current_branch(worktree_path: &Path) -> Option<String> { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(worktree_path) + .output() + .ok()?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if branch.is_empty() || branch == "HEAD" { + // Detached HEAD state - try to get the commit SHA + let sha_output = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .current_dir(worktree_path) + .output() + .ok()?; + if sha_output.status.success() { + Some(format!("(detached at {})", String::from_utf8_lossy(&sha_output.stdout).trim())) + } else { + Some("(detached)".to_string()) + } + } else { + Some(branch) + } + } else { + None + } +} + +/// Get a summary of the git status. +fn get_git_status_summary(worktree_path: &Path) -> GitStatusSummary { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(worktree_path) + .output(); + + let output = match output { + Ok(o) if o.status.success() => o, + _ => return GitStatusSummary { + summary: "unable to get git status".to_string(), + ..Default::default() + }, + }; + + let status_output = String::from_utf8_lossy(&output.stdout); + let mut staged = 0u32; + let mut modified = 0u32; + let mut untracked = 0u32; + let mut deleted = 0u32; + + for line in status_output.lines() { + if line.len() < 2 { + continue; + } + let index_status = line.chars().next().unwrap_or(' '); + let worktree_status = line.chars().nth(1).unwrap_or(' '); + + // Count staged changes (index column) + match index_status { + 'A' | 'M' | 'R' | 'C' => staged += 1, + 'D' => { + staged += 1; + deleted += 1; + } + _ => {} + } + + // Count unstaged changes (worktree column) + match worktree_status { + 'M' => modified += 1, + 'D' => { + modified += 1; + deleted += 1; + } + '?' => untracked += 1, + _ => {} + } + } + + // Build summary string + let mut parts = Vec::new(); + if staged > 0 { + parts.push(format!("{} staged", staged)); + } + if modified > 0 { + parts.push(format!("{} modified", modified)); + } + if untracked > 0 { + parts.push(format!("{} untracked", untracked)); + } + + let summary = if parts.is_empty() { + "clean".to_string() + } else { + parts.join(", ") + }; + + GitStatusSummary { + staged, + modified, + untracked, + deleted, + summary, + } +} + +/// Get information about the last checkpoint (most recent commit). +fn get_last_checkpoint(worktree_path: &Path) -> Option<CheckpointInfo> { + // Get the most recent commit info + let output = Command::new("git") + .args([ + "log", + "-1", + "--format=%h|%s|%aI", // short sha | subject | ISO 8601 date + ]) + .current_dir(worktree_path) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let log_output = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let parts: Vec<&str> = log_output.splitn(3, '|').collect(); + + if parts.len() < 3 { + return None; + } + + let sha = parts[0].to_string(); + let message = parts[1].to_string(); + let timestamp = DateTime::parse_from_rfc3339(parts[2]) + .ok()? + .with_timezone(&Utc); + + Some(CheckpointInfo { + sha, + message, + timestamp, + }) +} + +/// Get recent progress entries from the progress.log file. +fn get_recent_progress(worktree_path: &Path, max_entries: usize) -> Vec<ProgressEntry> { + let progress_log_path = worktree_path.join("progress.log"); + + let content = match std::fs::read_to_string(&progress_log_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let mut entries = Vec::new(); + + // Parse the progress.log file + // Expected format: + // [2024-01-15T10:30:00Z] ENTRY_TYPE + // key: value + // key: value + // (blank line) + let mut lines = content.lines().peekable(); + + while let Some(line) = lines.next() { + // Look for timestamp lines + if line.starts_with('[') && line.contains(']') { + if let Some(entry) = parse_progress_entry(line, &mut lines) { + entries.push(entry); + } + } + } + + // Return the most recent entries (up to max_entries) + let start = if entries.len() > max_entries { + entries.len() - max_entries + } else { + 0 + }; + + entries[start..].to_vec() +} + +/// Parse a single progress entry from the log. +fn parse_progress_entry<'a, I>(header_line: &str, lines: &mut std::iter::Peekable<I>) -> Option<ProgressEntry> +where + I: Iterator<Item = &'a str>, +{ + // Parse header: [2024-01-15T10:30:00Z] ENTRY_TYPE + let close_bracket = header_line.find(']')?; + let timestamp_str = &header_line[1..close_bracket]; + let entry_type = header_line[close_bracket + 1..].trim().to_string(); + + let timestamp = DateTime::parse_from_rfc3339(timestamp_str) + .ok()? + .with_timezone(&Utc); + + // Collect message from following lines until blank line or next entry + let mut message_parts = Vec::new(); + + while let Some(&next_line) = lines.peek() { + if next_line.is_empty() || next_line.starts_with('#') { + lines.next(); // consume blank/comment line + break; + } + if next_line.starts_with('[') { + // Next entry starts, don't consume + break; + } + + let line = lines.next().unwrap(); + // Extract key-value or just the line + if let Some(colon_pos) = line.find(':') { + let key = line[..colon_pos].trim(); + let value = line[colon_pos + 1..].trim(); + if key == "progress" || key == "message" || key == "reason" { + message_parts.push(value.to_string()); + } + } + } + + let message = if message_parts.is_empty() { + entry_type.clone() + } else { + message_parts.join("; ") + }; + + Some(ProgressEntry { + timestamp, + entry_type, + message, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_git_repo() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path(); + + // Initialize git repo + Command::new("git") + .args(["init"]) + .current_dir(path) + .output() + .unwrap(); + + // Configure git for commits + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(path) + .output() + .unwrap(); + + Command::new("git") + .args(["config", "user.name", "Test User"]) + .current_dir(path) + .output() + .unwrap(); + + // Create initial commit + fs::write(path.join("README.md"), "# Test").unwrap(); + Command::new("git") + .args(["add", "README.md"]) + .current_dir(path) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "Initial commit"]) + .current_dir(path) + .output() + .unwrap(); + + temp_dir + } + + #[test] + fn test_get_current_branch() { + let temp_dir = setup_git_repo(); + let branch = get_current_branch(temp_dir.path()); + // Default branch could be main or master depending on git config + assert!(branch.is_some()); + let branch_name = branch.unwrap(); + assert!(branch_name == "main" || branch_name == "master"); + } + + #[test] + fn test_git_status_clean() { + let temp_dir = setup_git_repo(); + let status = get_git_status_summary(temp_dir.path()); + assert_eq!(status.staged, 0); + assert_eq!(status.modified, 0); + assert_eq!(status.untracked, 0); + assert_eq!(status.summary, "clean"); + } + + #[test] + fn test_git_status_with_changes() { + let temp_dir = setup_git_repo(); + let path = temp_dir.path(); + + // Create untracked file + fs::write(path.join("new_file.txt"), "new content").unwrap(); + + // Modify tracked file + fs::write(path.join("README.md"), "# Modified").unwrap(); + + let status = get_git_status_summary(path); + assert_eq!(status.modified, 1); + assert_eq!(status.untracked, 1); + assert!(status.summary.contains("modified")); + assert!(status.summary.contains("untracked")); + } + + #[test] + fn test_get_last_checkpoint() { + let temp_dir = setup_git_repo(); + let checkpoint = get_last_checkpoint(temp_dir.path()); + assert!(checkpoint.is_some()); + + let checkpoint = checkpoint.unwrap(); + assert_eq!(checkpoint.message, "Initial commit"); + assert!(!checkpoint.sha.is_empty()); + } + + #[test] + fn test_build_context_recovery() { + let temp_dir = setup_git_repo(); + let markdown = build_context_recovery(temp_dir.path(), "execute", 5); + + assert!(markdown.contains("## Context Recovery")); + assert!(markdown.contains("Current branch:")); + assert!(markdown.contains("Git status:")); + assert!(markdown.contains("Last checkpoint:")); + assert!(markdown.contains("Current phase: execute")); + } + + #[test] + fn test_progress_log_parsing() { + let temp_dir = setup_git_repo(); + let path = temp_dir.path(); + + // Create a mock progress.log + let progress_content = r#"# progress.log +# Auto-generated by Makima - DO NOT EDIT MANUALLY + +[2024-01-15T10:30:00Z] TASK_START +task_id: test-123 +task_name: Test Task + +[2024-01-15T10:35:00Z] ITERATION 1 +progress: Analyzed codebase structure +files_modified: 2 + +[2024-01-15T10:40:00Z] ITERATION 2 +progress: Implemented first feature +files_modified: 3 +"#; + fs::write(path.join("progress.log"), progress_content).unwrap(); + + let entries = get_recent_progress(path, 5); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].entry_type, "TASK_START"); + assert_eq!(entries[1].entry_type, "ITERATION 1"); + assert_eq!(entries[2].entry_type, "ITERATION 2"); + } + + #[test] + fn test_context_recovery_to_markdown() { + let context = ContextRecovery { + current_branch: "feature/test".to_string(), + git_status: GitStatusSummary { + staged: 1, + modified: 2, + untracked: 0, + deleted: 0, + summary: "1 staged, 2 modified".to_string(), + }, + last_checkpoint: Some(CheckpointInfo { + sha: "abc1234".to_string(), + message: "Added feature X".to_string(), + timestamp: Utc::now(), + }), + recent_progress: vec![ + ProgressEntry { + timestamp: Utc::now(), + entry_type: "ITERATION 1".to_string(), + message: "Started implementation".to_string(), + }, + ], + current_phase: "execute".to_string(), + }; + + let markdown = context.to_markdown(); + assert!(markdown.contains("## Context Recovery")); + assert!(markdown.contains("feature/test")); + assert!(markdown.contains("1 staged, 2 modified")); + assert!(markdown.contains("abc1234")); + assert!(markdown.contains("Added feature X")); + assert!(markdown.contains("ITERATION 1")); + assert!(markdown.contains("execute")); + } +} |
