From 0c704909a975e36cc55bcd4f4a074c14dad8a075 Mon Sep 17 00:00:00 2001 From: Shahanur Islam Shagor Date: Sun, 6 Sep 2026 17:05:48 +0300 Subject: [PATCH 1/3] feat(openagent): restore verified checkpoints --- docs/OPENAGENT_ENGINEERING_PLAN.md | 2 +- src-tauri/src/lib.rs | 3 +- src-tauri/src/local_agent.rs | 279 ++++++++++++++++++++++++++++- src-tauri/src/openagent_runs.rs | 31 +++- src/api.ts | 21 +++ 5 files changed, 332 insertions(+), 4 deletions(-) diff --git a/docs/OPENAGENT_ENGINEERING_PLAN.md b/docs/OPENAGENT_ENGINEERING_PLAN.md index 1401b69..dda7fd1 100644 --- a/docs/OPENAGENT_ENGINEERING_PLAN.md +++ b/docs/OPENAGENT_ENGINEERING_PLAN.md @@ -61,4 +61,4 @@ The initial tool set covers directory listing, bounded file reads, text search, Stage 1 is complete. The bounded agent loop is connected to installed Nemotron 3.5 Lightning packages, uses the selected model ID, retains compatible lower-memory fallbacks, and runs inside the attached-root file boundary. -Stage 2 is in progress. OpenAgent now persists run state, ordered tool steps, bounded results, validation state, and before/after checkpoints. File-tool checkpoints include bounded file contents plus SHA-256 evidence and block destructive mutations that exceed the entry or byte limits. Startup recovery marks abandoned runs as interrupted, and typed desktop APIs expose run history and step details. Safe restore/replay, terminal-command snapshots, token/runtime metrics, and the visible timeline UI are still pending and must not be represented as complete. The OS-level process sandbox remains Stage 3. +Stage 2 is in progress. OpenAgent now persists run state, ordered tool steps, bounded results, validation state, and before/after checkpoints. File-tool checkpoints include bounded file contents plus SHA-256 evidence and block destructive mutations that exceed the entry or byte limits. Completed mutations can be restored only after the matching after-state passes conflict, digest, symlink, and attached-root checks; active runs cannot be restored. Startup recovery marks abandoned runs as interrupted, and typed desktop APIs expose run history, checkpoint IDs, step details, and restore results. Automatic interrupted-run replay, terminal-command snapshots, token/runtime metrics, and the visible timeline UI are still pending and must not be represented as complete. The OS-level process sandbox remains Stage 3. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f751ef..f5bb2cc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,7 @@ macro_rules! openmind_generate_handler { project_agent_status_for_conversation, list_openagent_runs, openagent_run_details, + restore_openagent_checkpoint, send_project_agent_message, regenerate_project_agent_message ] @@ -93,7 +94,7 @@ pub(crate) use google_workspace::{ }; pub(crate) use local_agent::{ project_agent_status_for_conversation, regenerate_project_agent_message, - send_project_agent_message, + restore_openagent_checkpoint, send_project_agent_message, }; pub(crate) use local_workspace::{ attach_project_workspace_folder, create_project_workspace_directory, diff --git a/src-tauri/src/local_agent.rs b/src-tauri/src/local_agent.rs index b8fbbc9..6a29dc2 100644 --- a/src-tauri/src/local_agent.rs +++ b/src-tauri/src/local_agent.rs @@ -1,5 +1,5 @@ use std::{ - collections::VecDeque, + collections::{HashSet, VecDeque}, fs, path::{Path, PathBuf}, time::Duration, @@ -91,6 +91,33 @@ struct AgentContext { conversation_context: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CheckpointSnapshot { + version: u32, + reversible: bool, + entries: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CheckpointEntry { + path: String, + kind: String, + sha256: Option, + content_base64: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointRestoreResult { + pub checkpoint_id: String, + pub restored_files: usize, + pub restored_directories: usize, + pub removed_paths: usize, + pub validation_required: bool, +} + #[tauri::command] pub fn project_agent_status_for_conversation( conversation_id: String, @@ -169,6 +196,63 @@ pub async fn regenerate_project_agent_message( run_agent_message(&app, &state, &conversation_id, &content, Some(user)).await } +#[tauri::command] +pub fn restore_openagent_checkpoint( + checkpoint_id: String, + state: State, +) -> Result { + let (project_id, run_status, before_json, after_json) = { + let db = state + .database + .lock() + .map_err(|_| AppError::internal("database lock poisoned"))?; + let before: (String, String, String, String) = db + .connection() + .query_row( + "SELECT r.project_id, r.status, c.step_id, c.workspace_snapshot_json + FROM openagent_checkpoints c + JOIN openagent_runs r ON r.id = c.run_id + WHERE c.id = ?1 AND c.kind = 'before_mutation'", + params![checkpoint_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()? + .ok_or_else(|| AppError::internal("restorable OpenAgent checkpoint not found"))?; + let after_json: String = db + .connection() + .query_row( + "SELECT workspace_snapshot_json FROM openagent_checkpoints + WHERE step_id = ?1 AND kind = 'after_mutation' + ORDER BY created_at DESC LIMIT 1", + params![before.2], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| AppError::internal("checkpoint has no completed mutation snapshot"))?; + (before.0, before.1, before.3, after_json) + }; + if run_status == "running" { + return Err(AppError::internal( + "cannot restore a checkpoint while its OpenAgent run is active", + )); + } + let before = parse_checkpoint_snapshot(&before_json)?; + let after = parse_checkpoint_snapshot(&after_json)?; + if before.version != 2 || after.version != 2 || !before.reversible || !after.reversible { + return Err(AppError::internal("checkpoint format is not safely restorable")); + } + let workspace = { + let db = state + .database + .lock() + .map_err(|_| AppError::internal("database lock poisoned"))?; + load_workspace_config(&db, &project_id)? + }; + preflight_checkpoint_restore(&workspace, &after.entries)?; + let result = apply_checkpoint_restore(&checkpoint_id, &workspace, &before.entries)?; + Ok(result) +} + async fn run_agent_message( app: &AppHandle, state: &State<'_, AppState>, @@ -837,6 +921,151 @@ fn capture_checkpoint_path( Ok(()) } +fn parse_checkpoint_snapshot(raw: &str) -> Result { + serde_json::from_str(raw) + .map_err(|error| AppError::internal(format!("invalid checkpoint snapshot: {error}"))) +} + +fn checkpoint_path(config: &AgentWorkspaceConfig, raw: &str) -> Result { + let path = PathBuf::from(raw); + if !path.is_absolute() { + return Err(AppError::internal("checkpoint contains a non-absolute path")); + } + let security_path = if path.exists() { + fs::canonicalize(&path)? + } else { + canonical_existing_parent(&path)? + }; + let contained = config.roots.iter().any(|root| { + fs::canonicalize(&root.path) + .map(|root| security_path.starts_with(root)) + .unwrap_or(false) + }); + if !contained { + return Err(AppError::internal( + "checkpoint path is outside the currently attached workspace", + )); + } + Ok(path) +} + +fn preflight_checkpoint_restore( + config: &AgentWorkspaceConfig, + expected: &[CheckpointEntry], +) -> Result<(), AppError> { + let expected_paths = expected + .iter() + .map(|entry| display_path(Path::new(&entry.path))) + .collect::>(); + for entry in expected { + let path = checkpoint_path(config, &entry.path)?; + let matches = match entry.kind.as_str() { + "missing" => !path.exists(), + "directory" => path.is_dir() && !fs::symlink_metadata(&path)?.file_type().is_symlink(), + "file" => { + if !path.is_file() || fs::symlink_metadata(&path)?.file_type().is_symlink() { + false + } else { + let content = fs::read(&path)?; + entry.sha256.as_deref() + == Some(format!("{:x}", Sha256::digest(content)).as_str()) + } + } + _ => return Err(AppError::internal("checkpoint contains an unknown entry kind")), + }; + if !matches { + return Err(AppError::internal(format!( + "checkpoint restore conflict: {} changed after the OpenAgent step", + display_path(&path) + ))); + } + if entry.kind == "directory" { + for child in fs::read_dir(&path)? { + let child = display_path(&child?.path()); + if !expected_paths.contains(&child) { + return Err(AppError::internal(format!( + "checkpoint restore conflict: {child} was added after the OpenAgent step" + ))); + } + } + } + } + Ok(()) +} + +fn apply_checkpoint_restore( + checkpoint_id: &str, + config: &AgentWorkspaceConfig, + entries: &[CheckpointEntry], +) -> Result { + let mut paths = entries + .iter() + .map(|entry| Ok((checkpoint_path(config, &entry.path)?, entry))) + .collect::, AppError>>()?; + for (_, entry) in &paths { + if entry.kind == "file" { + let encoded = entry.content_base64.as_deref().ok_or_else(|| { + AppError::internal("checkpoint file is missing its content payload") + })?; + let content = BASE64 + .decode(encoded) + .map_err(|_| AppError::internal("checkpoint file payload is invalid"))?; + let digest = format!("{:x}", Sha256::digest(&content)); + if entry.sha256.as_deref() != Some(digest.as_str()) { + return Err(AppError::internal("checkpoint file digest verification failed")); + } + } + } + paths.sort_by_key(|(path, _)| std::cmp::Reverse(path.components().count())); + let mut removed_paths = 0; + for (path, entry) in &paths { + if entry.kind == "missing" && path.exists() { + if fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(AppError::internal("refusing to restore over a symlink")); + } + if path.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + removed_paths += 1; + } + } + paths.sort_by_key(|(path, _)| path.components().count()); + let mut restored_directories = 0; + let mut restored_files = 0; + for (path, entry) in paths { + match entry.kind.as_str() { + "directory" => { + fs::create_dir_all(&path)?; + restored_directories += 1; + } + "file" => { + let encoded = entry.content_base64.as_deref().ok_or_else(|| { + AppError::internal("checkpoint file is missing its content payload") + })?; + let content = BASE64 + .decode(encoded) + .map_err(|_| AppError::internal("checkpoint file payload is invalid"))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content)?; + restored_files += 1; + } + "missing" => {} + _ => return Err(AppError::internal("checkpoint contains an unknown entry kind")), + } + } + Ok(CheckpointRestoreResult { + checkpoint_id: checkpoint_id.to_string(), + restored_files, + restored_directories, + removed_paths, + validation_required: true, + }) +} + fn finish_durable_run( state: &State<'_, AppState>, run_id: &str, @@ -2357,6 +2586,54 @@ mod tests { ); } + #[test] + fn checkpoint_restore_reinstates_verified_file_content() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("src.txt"); + fs::write(&file, b"before").unwrap(); + let config = AgentWorkspaceConfig { + full_pc_access: false, + roots: vec![AgentWorkspaceRoot { + id: "root".to_string(), + path: temp.path().display().to_string(), + created_at: "now".to_string(), + }], + }; + let action = json!({"rootId": "root", "path": "src.txt", "content": "after"}); + let before = capture_checkpoint_entries(&config, "write_file", &action).unwrap(); + fs::write(&file, b"after").unwrap(); + let after = capture_checkpoint_entries(&config, "write_file", &action).unwrap(); + + preflight_checkpoint_restore(&config, &after).unwrap(); + let result = apply_checkpoint_restore("checkpoint", &config, &before).unwrap(); + + assert_eq!(fs::read(&file).unwrap(), b"before"); + assert_eq!(result.restored_files, 1); + assert!(result.validation_required); + } + + #[test] + fn checkpoint_restore_rejects_changes_made_after_agent_step() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("src.txt"); + fs::write(&file, b"after").unwrap(); + let config = AgentWorkspaceConfig { + full_pc_access: false, + roots: vec![AgentWorkspaceRoot { + id: "root".to_string(), + path: temp.path().display().to_string(), + created_at: "now".to_string(), + }], + }; + let action = json!({"rootId": "root", "path": "src.txt", "content": "after"}); + let after = capture_checkpoint_entries(&config, "write_file", &action).unwrap(); + fs::write(&file, b"user edit").unwrap(); + + let error = preflight_checkpoint_restore(&config, &after).unwrap_err(); + assert!(error.to_string().contains("restore conflict")); + assert_eq!(fs::read(&file).unwrap(), b"user edit"); + } + #[test] fn recognizes_common_validation_commands() { assert!(is_validation_command("npm run lint")); diff --git a/src-tauri/src/openagent_runs.rs b/src-tauri/src/openagent_runs.rs index c9a3482..49367c4 100644 --- a/src-tauri/src/openagent_runs.rs +++ b/src-tauri/src/openagent_runs.rs @@ -49,6 +49,17 @@ pub struct OpenAgentStep { pub struct OpenAgentRunDetails { pub run: OpenAgentRun, pub steps: Vec, + pub checkpoints: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenAgentCheckpoint { + pub id: String, + pub run_id: String, + pub step_id: String, + pub kind: String, + pub created_at: String, } pub struct OpenAgentRunRepository<'a> { @@ -216,7 +227,25 @@ impl<'a> OpenAgentRunRepository<'a> { )?; let rows = statement.query_map(params![run_id], map_step)?; let steps = rows.collect::, _>>()?; - Ok(Some(OpenAgentRunDetails { run, steps })) + let mut statement = self.database.connection().prepare( + "SELECT id, run_id, step_id, kind, created_at FROM openagent_checkpoints + WHERE run_id = ?1 ORDER BY created_at ASC", + )?; + let rows = statement.query_map(params![run_id], |row| { + Ok(OpenAgentCheckpoint { + id: row.get(0)?, + run_id: row.get(1)?, + step_id: row.get(2)?, + kind: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + let checkpoints = rows.collect::, _>>()?; + Ok(Some(OpenAgentRunDetails { + run, + steps, + checkpoints, + })) } } diff --git a/src/api.ts b/src/api.ts index c6042bc..7f24831 100644 --- a/src/api.ts +++ b/src/api.ts @@ -72,6 +72,23 @@ export type OpenAgentStep = { export type OpenAgentRunDetails = { run: OpenAgentRun; steps: OpenAgentStep[]; + checkpoints: OpenAgentCheckpoint[]; +}; + +export type OpenAgentCheckpoint = { + id: string; + runId: string; + stepId: string; + kind: "before_mutation" | "after_mutation" | "validation"; + createdAt: string; +}; + +export type CheckpointRestoreResult = { + checkpointId: string; + restoredFiles: number; + restoredDirectories: number; + removedPaths: number; + validationRequired: boolean; }; type RuntimeBootstrapSnapshot = { @@ -302,6 +319,10 @@ export const api = { connectedInvoke("list_openagent_runs", { conversationId, limit }), openAgentRunDetails: (runId: string) => connectedInvoke("openagent_run_details", { runId }), + restoreOpenAgentCheckpoint: (checkpointId: string) => + connectedInvoke("restore_openagent_checkpoint", { + checkpointId, + }), sendChatMessage: async ( conversationId: string, content: string, From b3d15dba7979a3247ef52081161bf2fe1f10309f Mon Sep 17 00:00:00 2001 From: Shahanur Islam Shagor Date: Sun, 6 Sep 2026 17:07:48 +0300 Subject: [PATCH 2/3] style(openagent): apply Rust formatting --- src-tauri/src/local_agent.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/local_agent.rs b/src-tauri/src/local_agent.rs index 6a29dc2..95bd7b3 100644 --- a/src-tauri/src/local_agent.rs +++ b/src-tauri/src/local_agent.rs @@ -239,7 +239,9 @@ pub fn restore_openagent_checkpoint( let before = parse_checkpoint_snapshot(&before_json)?; let after = parse_checkpoint_snapshot(&after_json)?; if before.version != 2 || after.version != 2 || !before.reversible || !after.reversible { - return Err(AppError::internal("checkpoint format is not safely restorable")); + return Err(AppError::internal( + "checkpoint format is not safely restorable", + )); } let workspace = { let db = state @@ -929,7 +931,9 @@ fn parse_checkpoint_snapshot(raw: &str) -> Result fn checkpoint_path(config: &AgentWorkspaceConfig, raw: &str) -> Result { let path = PathBuf::from(raw); if !path.is_absolute() { - return Err(AppError::internal("checkpoint contains a non-absolute path")); + return Err(AppError::internal( + "checkpoint contains a non-absolute path", + )); } let security_path = if path.exists() { fs::canonicalize(&path)? @@ -971,7 +975,11 @@ fn preflight_checkpoint_restore( == Some(format!("{:x}", Sha256::digest(content)).as_str()) } } - _ => return Err(AppError::internal("checkpoint contains an unknown entry kind")), + _ => { + return Err(AppError::internal( + "checkpoint contains an unknown entry kind", + )) + } }; if !matches { return Err(AppError::internal(format!( @@ -1012,7 +1020,9 @@ fn apply_checkpoint_restore( .map_err(|_| AppError::internal("checkpoint file payload is invalid"))?; let digest = format!("{:x}", Sha256::digest(&content)); if entry.sha256.as_deref() != Some(digest.as_str()) { - return Err(AppError::internal("checkpoint file digest verification failed")); + return Err(AppError::internal( + "checkpoint file digest verification failed", + )); } } } @@ -1054,7 +1064,11 @@ fn apply_checkpoint_restore( restored_files += 1; } "missing" => {} - _ => return Err(AppError::internal("checkpoint contains an unknown entry kind")), + _ => { + return Err(AppError::internal( + "checkpoint contains an unknown entry kind", + )) + } } } Ok(CheckpointRestoreResult { From 1e5da875be27cdfba93b3066588fdf66c427c449 Mon Sep 17 00:00:00 2001 From: Shahanur Islam Shagor Date: Sun, 6 Sep 2026 17:10:35 +0300 Subject: [PATCH 3/3] test(openagent): decode checkpoint fixtures --- src-tauri/src/local_agent.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/local_agent.rs b/src-tauri/src/local_agent.rs index 95bd7b3..fa46a66 100644 --- a/src-tauri/src/local_agent.rs +++ b/src-tauri/src/local_agent.rs @@ -2489,6 +2489,13 @@ mod tests { use super::*; use crate::model_registry::ModelLifecycleState; + fn decoded_checkpoint_entries(values: Vec) -> Vec { + values + .into_iter() + .map(|value| serde_json::from_value(value).unwrap()) + .collect() + } + fn agent_model(id: &str, repository: &str, enabled: bool) -> ModelRecord { ModelRecord { id: id.to_string(), @@ -2614,9 +2621,13 @@ mod tests { }], }; let action = json!({"rootId": "root", "path": "src.txt", "content": "after"}); - let before = capture_checkpoint_entries(&config, "write_file", &action).unwrap(); + let before = decoded_checkpoint_entries( + capture_checkpoint_entries(&config, "write_file", &action).unwrap(), + ); fs::write(&file, b"after").unwrap(); - let after = capture_checkpoint_entries(&config, "write_file", &action).unwrap(); + let after = decoded_checkpoint_entries( + capture_checkpoint_entries(&config, "write_file", &action).unwrap(), + ); preflight_checkpoint_restore(&config, &after).unwrap(); let result = apply_checkpoint_restore("checkpoint", &config, &before).unwrap(); @@ -2640,7 +2651,9 @@ mod tests { }], }; let action = json!({"rootId": "root", "path": "src.txt", "content": "after"}); - let after = capture_checkpoint_entries(&config, "write_file", &action).unwrap(); + let after = decoded_checkpoint_entries( + capture_checkpoint_entries(&config, "write_file", &action).unwrap(), + ); fs::write(&file, b"user edit").unwrap(); let error = preflight_checkpoint_restore(&config, &after).unwrap_err();