summaryrefslogtreecommitdiff
path: root/makima/src/daemon/task/context_recovery.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/task/context_recovery.rs')
-rw-r--r--makima/src/daemon/task/context_recovery.rs554
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"));
+ }
+}