feat(M12): complete milestone with plugins, checkpointing, and rewind
Implements the remaining M12 features from AGENTS.md: **Plugin System (crates/platform/plugins)** - Plugin manifest schema with plugin.json support - Plugin loader for commands, agents, skills, hooks, and MCP servers - Discovers plugins from ~/.config/owlen/plugins and .owlen/plugins - Includes comprehensive tests (4 passing) **Session Checkpointing (crates/core/agent)** - Checkpoint struct capturing session state and file diffs - CheckpointManager with snapshot, diff, save, load, and rewind capabilities - File diff tracking with before/after content - Checkpoint persistence to .owlen/checkpoints/ - Includes comprehensive tests (6 passing) **REPL Commands (crates/app/cli)** - /checkpoint - Save current session with file diffs - /checkpoints - List all saved checkpoints - /rewind <id> - Restore session and files from checkpoint - Updated /help documentation M12 milestone now fully complete: ✅ /permissions, /status, /cost (previously implemented) ✅ Checkpointing and /rewind ✅ Plugin loader with manifest schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,3 +19,4 @@ tools-fs = { path = "../../tools/fs" }
|
||||
tools-bash = { path = "../../tools/bash" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13"
|
||||
|
||||
@@ -6,7 +6,10 @@ use llm_ollama::{ChatMessage, OllamaClient, OllamaOptions, Tool, ToolFunction, T
|
||||
use permissions::{PermissionDecision, PermissionManager, Tool as PermTool};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub use session::{SessionStats, SessionHistory, ToolCallRecord};
|
||||
pub use session::{
|
||||
SessionStats, SessionHistory, ToolCallRecord,
|
||||
Checkpoint, CheckpointManager, FileDiff,
|
||||
};
|
||||
|
||||
/// Define all available tools for the LLM
|
||||
pub fn get_tool_definitions() -> Vec<Tool> {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -97,3 +101,195 @@ impl Default for SessionHistory {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a file modification with before/after content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileDiff {
|
||||
pub path: PathBuf,
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub timestamp: SystemTime,
|
||||
}
|
||||
|
||||
impl FileDiff {
|
||||
/// Create a new file diff
|
||||
pub fn new(path: PathBuf, before: String, after: String) -> Self {
|
||||
Self {
|
||||
path,
|
||||
before,
|
||||
after,
|
||||
timestamp: SystemTime::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkpoint captures the state of a session at a point in time
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Checkpoint {
|
||||
pub id: String,
|
||||
pub timestamp: SystemTime,
|
||||
pub stats: SessionStats,
|
||||
pub user_prompts: Vec<String>,
|
||||
pub assistant_responses: Vec<String>,
|
||||
pub tool_calls: Vec<ToolCallRecord>,
|
||||
pub file_diffs: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
impl Checkpoint {
|
||||
/// Create a new checkpoint from current session state
|
||||
pub fn new(
|
||||
id: String,
|
||||
stats: SessionStats,
|
||||
history: &SessionHistory,
|
||||
file_diffs: Vec<FileDiff>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
timestamp: SystemTime::now(),
|
||||
stats,
|
||||
user_prompts: history.user_prompts.clone(),
|
||||
assistant_responses: history.assistant_responses.clone(),
|
||||
tool_calls: history.tool_calls.clone(),
|
||||
file_diffs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save checkpoint to disk
|
||||
pub fn save(&self, checkpoint_dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(checkpoint_dir)?;
|
||||
let path = checkpoint_dir.join(format!("{}.json", self.id));
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load checkpoint from disk
|
||||
pub fn load(checkpoint_dir: &Path, id: &str) -> Result<Self> {
|
||||
let path = checkpoint_dir.join(format!("{}.json", id));
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| eyre!("Failed to read checkpoint: {}", e))?;
|
||||
let checkpoint: Checkpoint = serde_json::from_str(&content)
|
||||
.map_err(|e| eyre!("Failed to parse checkpoint: {}", e))?;
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
/// List all available checkpoints in a directory
|
||||
pub fn list(checkpoint_dir: &Path) -> Result<Vec<String>> {
|
||||
if !checkpoint_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut checkpoints = Vec::new();
|
||||
for entry in fs::read_dir(checkpoint_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
checkpoints.push(stem.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by checkpoint ID (which includes timestamp)
|
||||
checkpoints.sort();
|
||||
Ok(checkpoints)
|
||||
}
|
||||
}
|
||||
|
||||
/// Session checkpoint manager
|
||||
pub struct CheckpointManager {
|
||||
checkpoint_dir: PathBuf,
|
||||
file_snapshots: HashMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
impl CheckpointManager {
|
||||
/// Create a new checkpoint manager
|
||||
pub fn new(checkpoint_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
checkpoint_dir,
|
||||
file_snapshots: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot a file's current content before modification
|
||||
pub fn snapshot_file(&mut self, path: &Path) -> Result<()> {
|
||||
if !self.file_snapshots.contains_key(path) {
|
||||
let content = fs::read_to_string(path).unwrap_or_default();
|
||||
self.file_snapshots.insert(path.to_path_buf(), content);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a file diff after modification
|
||||
pub fn create_diff(&self, path: &Path) -> Result<Option<FileDiff>> {
|
||||
if let Some(before) = self.file_snapshots.get(path) {
|
||||
let after = fs::read_to_string(path).unwrap_or_default();
|
||||
if before != &after {
|
||||
Ok(Some(FileDiff::new(
|
||||
path.to_path_buf(),
|
||||
before.clone(),
|
||||
after,
|
||||
)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all file diffs since last checkpoint
|
||||
pub fn get_all_diffs(&self) -> Result<Vec<FileDiff>> {
|
||||
let mut diffs = Vec::new();
|
||||
for (path, before) in &self.file_snapshots {
|
||||
let after = fs::read_to_string(path).unwrap_or_default();
|
||||
if before != &after {
|
||||
diffs.push(FileDiff::new(path.clone(), before.clone(), after));
|
||||
}
|
||||
}
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
/// Clear file snapshots
|
||||
pub fn clear_snapshots(&mut self) {
|
||||
self.file_snapshots.clear();
|
||||
}
|
||||
|
||||
/// Save a checkpoint
|
||||
pub fn save_checkpoint(
|
||||
&mut self,
|
||||
id: String,
|
||||
stats: SessionStats,
|
||||
history: &SessionHistory,
|
||||
) -> Result<Checkpoint> {
|
||||
let file_diffs = self.get_all_diffs()?;
|
||||
let checkpoint = Checkpoint::new(id, stats, history, file_diffs);
|
||||
checkpoint.save(&self.checkpoint_dir)?;
|
||||
self.clear_snapshots();
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
/// Load a checkpoint
|
||||
pub fn load_checkpoint(&self, id: &str) -> Result<Checkpoint> {
|
||||
Checkpoint::load(&self.checkpoint_dir, id)
|
||||
}
|
||||
|
||||
/// List all checkpoints
|
||||
pub fn list_checkpoints(&self) -> Result<Vec<String>> {
|
||||
Checkpoint::list(&self.checkpoint_dir)
|
||||
}
|
||||
|
||||
/// Rewind to a checkpoint by restoring file contents
|
||||
pub fn rewind_to(&self, checkpoint_id: &str) -> Result<Vec<PathBuf>> {
|
||||
let checkpoint = self.load_checkpoint(checkpoint_id)?;
|
||||
let mut restored_files = Vec::new();
|
||||
|
||||
// Restore files from diffs (revert to 'before' state)
|
||||
for diff in &checkpoint.file_diffs {
|
||||
fs::write(&diff.path, &diff.before)?;
|
||||
restored_files.push(diff.path.clone());
|
||||
}
|
||||
|
||||
Ok(restored_files)
|
||||
}
|
||||
}
|
||||
|
||||
210
crates/core/agent/tests/checkpoint.rs
Normal file
210
crates/core/agent/tests/checkpoint.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use agent_core::{Checkpoint, CheckpointManager, FileDiff, SessionHistory, SessionStats};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_save_and_load() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().to_path_buf();
|
||||
|
||||
let stats = SessionStats::new();
|
||||
let mut history = SessionHistory::new();
|
||||
history.add_user_message("Hello".to_string());
|
||||
history.add_assistant_message("Hi there!".to_string());
|
||||
|
||||
let file_diffs = vec![FileDiff::new(
|
||||
PathBuf::from("test.txt"),
|
||||
"before".to_string(),
|
||||
"after".to_string(),
|
||||
)];
|
||||
|
||||
let checkpoint = Checkpoint::new(
|
||||
"test-checkpoint".to_string(),
|
||||
stats.clone(),
|
||||
&history,
|
||||
file_diffs,
|
||||
);
|
||||
|
||||
// Save checkpoint
|
||||
checkpoint.save(&checkpoint_dir).unwrap();
|
||||
|
||||
// Load checkpoint
|
||||
let loaded = Checkpoint::load(&checkpoint_dir, "test-checkpoint").unwrap();
|
||||
|
||||
assert_eq!(loaded.id, "test-checkpoint");
|
||||
assert_eq!(loaded.user_prompts, vec!["Hello"]);
|
||||
assert_eq!(loaded.assistant_responses, vec!["Hi there!"]);
|
||||
assert_eq!(loaded.file_diffs.len(), 1);
|
||||
assert_eq!(loaded.file_diffs[0].path, PathBuf::from("test.txt"));
|
||||
assert_eq!(loaded.file_diffs[0].before, "before");
|
||||
assert_eq!(loaded.file_diffs[0].after, "after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_list() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create a few checkpoints
|
||||
for i in 1..=3 {
|
||||
let checkpoint = Checkpoint::new(
|
||||
format!("checkpoint-{}", i),
|
||||
SessionStats::new(),
|
||||
&SessionHistory::new(),
|
||||
vec![],
|
||||
);
|
||||
checkpoint.save(&checkpoint_dir).unwrap();
|
||||
}
|
||||
|
||||
let checkpoints = Checkpoint::list(&checkpoint_dir).unwrap();
|
||||
assert_eq!(checkpoints.len(), 3);
|
||||
assert!(checkpoints.contains(&"checkpoint-1".to_string()));
|
||||
assert!(checkpoints.contains(&"checkpoint-2".to_string()));
|
||||
assert!(checkpoints.contains(&"checkpoint-3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_manager_snapshot_and_diff() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().join("checkpoints");
|
||||
let test_file = temp_dir.path().join("test.txt");
|
||||
|
||||
// Create initial file content
|
||||
fs::write(&test_file, "initial content").unwrap();
|
||||
|
||||
let mut manager = CheckpointManager::new(checkpoint_dir.clone());
|
||||
|
||||
// Snapshot the file
|
||||
manager.snapshot_file(&test_file).unwrap();
|
||||
|
||||
// Modify the file
|
||||
fs::write(&test_file, "modified content").unwrap();
|
||||
|
||||
// Create a diff
|
||||
let diff = manager.create_diff(&test_file).unwrap();
|
||||
assert!(diff.is_some());
|
||||
|
||||
let diff = diff.unwrap();
|
||||
assert_eq!(diff.path, test_file);
|
||||
assert_eq!(diff.before, "initial content");
|
||||
assert_eq!(diff.after, "modified content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_manager_save_and_restore() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().join("checkpoints");
|
||||
let test_file = temp_dir.path().join("test.txt");
|
||||
|
||||
// Create initial file content
|
||||
fs::write(&test_file, "initial content").unwrap();
|
||||
|
||||
let mut manager = CheckpointManager::new(checkpoint_dir.clone());
|
||||
|
||||
// Snapshot the file
|
||||
manager.snapshot_file(&test_file).unwrap();
|
||||
|
||||
// Modify the file
|
||||
fs::write(&test_file, "modified content").unwrap();
|
||||
|
||||
// Save checkpoint
|
||||
let mut history = SessionHistory::new();
|
||||
history.add_user_message("test".to_string());
|
||||
let checkpoint = manager
|
||||
.save_checkpoint("test-checkpoint".to_string(), SessionStats::new(), &history)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(checkpoint.file_diffs.len(), 1);
|
||||
assert_eq!(checkpoint.file_diffs[0].before, "initial content");
|
||||
assert_eq!(checkpoint.file_diffs[0].after, "modified content");
|
||||
|
||||
// Modify file again
|
||||
fs::write(&test_file, "final content").unwrap();
|
||||
assert_eq!(fs::read_to_string(&test_file).unwrap(), "final content");
|
||||
|
||||
// Rewind to checkpoint
|
||||
let restored_files = manager.rewind_to("test-checkpoint").unwrap();
|
||||
assert_eq!(restored_files.len(), 1);
|
||||
assert_eq!(restored_files[0], test_file);
|
||||
|
||||
// File should be reverted to initial content (before the checkpoint)
|
||||
assert_eq!(fs::read_to_string(&test_file).unwrap(), "initial content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_manager_multiple_files() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().join("checkpoints");
|
||||
let test_file1 = temp_dir.path().join("file1.txt");
|
||||
let test_file2 = temp_dir.path().join("file2.txt");
|
||||
|
||||
// Create initial files
|
||||
fs::write(&test_file1, "file1 initial").unwrap();
|
||||
fs::write(&test_file2, "file2 initial").unwrap();
|
||||
|
||||
let mut manager = CheckpointManager::new(checkpoint_dir.clone());
|
||||
|
||||
// Snapshot both files
|
||||
manager.snapshot_file(&test_file1).unwrap();
|
||||
manager.snapshot_file(&test_file2).unwrap();
|
||||
|
||||
// Modify both files
|
||||
fs::write(&test_file1, "file1 modified").unwrap();
|
||||
fs::write(&test_file2, "file2 modified").unwrap();
|
||||
|
||||
// Save checkpoint
|
||||
let checkpoint = manager
|
||||
.save_checkpoint(
|
||||
"multi-file-checkpoint".to_string(),
|
||||
SessionStats::new(),
|
||||
&SessionHistory::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(checkpoint.file_diffs.len(), 2);
|
||||
|
||||
// Modify files again
|
||||
fs::write(&test_file1, "file1 final").unwrap();
|
||||
fs::write(&test_file2, "file2 final").unwrap();
|
||||
|
||||
// Rewind
|
||||
let restored_files = manager.rewind_to("multi-file-checkpoint").unwrap();
|
||||
assert_eq!(restored_files.len(), 2);
|
||||
|
||||
// Both files should be reverted
|
||||
assert_eq!(fs::read_to_string(&test_file1).unwrap(), "file1 initial");
|
||||
assert_eq!(fs::read_to_string(&test_file2).unwrap(), "file2 initial");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_no_changes() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().join("checkpoints");
|
||||
let test_file = temp_dir.path().join("test.txt");
|
||||
|
||||
// Create file
|
||||
fs::write(&test_file, "content").unwrap();
|
||||
|
||||
let mut manager = CheckpointManager::new(checkpoint_dir.clone());
|
||||
|
||||
// Snapshot the file
|
||||
manager.snapshot_file(&test_file).unwrap();
|
||||
|
||||
// Don't modify the file
|
||||
|
||||
// Create diff - should be None because nothing changed
|
||||
let diff = manager.create_diff(&test_file).unwrap();
|
||||
assert!(diff.is_none());
|
||||
|
||||
// Save checkpoint - should have no diffs
|
||||
let checkpoint = manager
|
||||
.save_checkpoint(
|
||||
"no-change-checkpoint".to_string(),
|
||||
SessionStats::new(),
|
||||
&SessionHistory::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(checkpoint.file_diffs.len(), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user