| 1 | //! Host operations moved out of `groups/debug/undo.rs` and `receipts.rs`. |
| 2 | //! Snapshot safety and authoritative state mutation remain here. The portable |
| 3 | //! commands receive typed observations/outcomes, never completed command text. |
| 4 | //! Existing synchronous dispatch timing is preserved; this does not introduce |
| 5 | //! an asynchronous I/O execution model. |
| 6 | |
| 7 | use super::SharedCommandHost; |
| 8 | use crate::dependencies::{ExternalTool, Git}; |
| 9 | use crate::tui::app::App; |
| 10 | use crate::tui::history::HistoryCell; |
| 11 | use codewhale_command_contract::facets::*; |
| 12 | use codewhale_models::ContentBlock; |
| 13 | use std::path::PathBuf; |
| 14 | |
| 15 | pub(super) struct DebugOperationsAdapter<'a> { |
| 16 | pub(super) host: SharedCommandHost<'a>, |
| 17 | } |
| 18 | |
| 19 | impl CommandDebugReceiptsContext for DebugOperationsAdapter<'_> { |
| 20 | fn receipt(&self, turn: Option<&str>) -> Result<Receipt, DebugReceiptError> { |
| 21 | let app = self.host.app.borrow(); |
| 22 | let approvals = match app.current_session_id.as_deref() { |
| 23 | Some(id) => crate::approval_log::ApprovalReceiptStore::default_location() |
| 24 | .and_then(|store| store.load(id)) |
| 25 | .map_err(|error| DebugReceiptError::ApprovalLog(error.to_string()))?, |
| 26 | None => Vec::new(), |
| 27 | }; |
| 28 | let source = ReceiptSource { |
| 29 | kind: SourceKind::Session, |
| 30 | id: app |
| 31 | .current_session_id |
| 32 | .clone() |
| 33 | .unwrap_or_else(|| "unsaved".to_string()), |
| 34 | title: app.session_title.clone(), |
| 35 | workspace: Some(app.workspace.display().to_string()), |
| 36 | model: Some(app.model.clone()), |
| 37 | started_at: Some(app.session_started_at), |
| 38 | updated_at: None, |
| 39 | }; |
| 40 | crate::receipts::session_receipt(source, &app.api_messages, &approvals, turn) |
| 41 | .map_err(|error| DebugReceiptError::Build(error.to_string())) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | impl CommandDebugChangeContext for DebugOperationsAdapter<'_> { |
| 46 | fn change_projection(&self) -> DebugChangeProjection { |
| 47 | let app = self.host.app.borrow(); |
| 48 | DebugChangeProjection { |
| 49 | changelog: include_str!("../../../CHANGELOG.md"), |
| 50 | is_english: app.ui_locale == codewhale_localization::Locale::En, |
| 51 | translation_available: !app.offline_mode && !app.onboarding_needs_api_key, |
| 52 | translation_target: app.ui_locale.translation_target_name(), |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | impl CommandDebugHistoryContext for DebugOperationsAdapter<'_> { |
| 58 | fn last_user_input(&self) -> Option<String> { |
| 59 | self.host |
| 60 | .app |
| 61 | .borrow() |
| 62 | .history |
| 63 | .iter() |
| 64 | .rev() |
| 65 | .find_map(|cell| match cell { |
| 66 | HistoryCell::User { content } => Some(content.clone()), |
| 67 | _ => None, |
| 68 | }) |
| 69 | } |
| 70 | fn load_composer(&mut self, input: String) { |
| 71 | let mut app = self.host.app.borrow_mut(); |
| 72 | // A queued follow-up still open for editing would otherwise stay bound |
| 73 | // to the composer and be overwritten, or sent in place of this edit. |
| 74 | // Return it to the queue first — the same hand-back as Esc. |
| 75 | if app.cancel_queued_draft_edit() { |
| 76 | app.status_message = Some("Queued edit canceled; follow-up restored".to_string()); |
| 77 | } |
| 78 | app.input = input; |
| 79 | app.cursor_position = app.input.chars().count(); |
| 80 | app.edit_in_progress = true; |
| 81 | } |
| 82 | fn undo_conversation(&mut self) -> DebugConversationUndo { |
| 83 | undo_conversation_for_engine(&mut self.host.app.borrow_mut()) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | impl CommandDebugUndoContext for DebugOperationsAdapter<'_> { |
| 88 | fn undo_files(&mut self) -> DebugUndoOutcome { |
| 89 | undo_files(&mut self.host.app.borrow_mut()) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | impl CommandDebugDiffContext for DebugOperationsAdapter<'_> { |
| 94 | fn diff(&self) -> DebugDiffObservation { |
| 95 | let workspace = self.host.app.borrow().workspace.clone(); |
| 96 | let Some(mut name_only_cmd) = Git::command() else { |
| 97 | return DebugDiffObservation::GitUnavailable; |
| 98 | }; |
| 99 | let Some(mut stat_cmd) = Git::command() else { |
| 100 | return DebugDiffObservation::GitUnavailable; |
| 101 | }; |
| 102 | let names = name_only_cmd |
| 103 | .args(["diff", "--name-only"]) |
| 104 | .current_dir(&workspace) |
| 105 | .output(); |
| 106 | let stat = stat_cmd |
| 107 | .args(["diff", "--stat"]) |
| 108 | .current_dir(&workspace) |
| 109 | .output(); |
| 110 | match (names, stat) { |
| 111 | (Ok(names), Ok(stat)) => DebugDiffObservation::Output { |
| 112 | names: String::from_utf8_lossy(&names.stdout).into_owned(), |
| 113 | stat: String::from_utf8_lossy(&stat.stdout).into_owned(), |
| 114 | }, |
| 115 | (Err(error), _) | (_, Err(error)) => DebugDiffObservation::Failed(error.to_string()), |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /// Prepare the rollback; the UI action owns Engine acknowledgement and save. |
| 121 | /// The last real user boundary includes following tool results/runtime notes. |
| 122 | pub(in crate::commands) fn undo_conversation_for_engine(app: &mut App) -> DebugConversationUndo { |
| 123 | let removed = app |
| 124 | .history |
| 125 | .iter() |
| 126 | .rposition(|cell| matches!(cell, HistoryCell::User { .. })) |
| 127 | .map_or(0, |index| app.history.len() - index); |
| 128 | let mut sync = session_sync_payload(app); |
| 129 | if let Some(index) = sync.messages.iter().rposition(|message| { |
| 130 | !matches!( |
| 131 | crate::runtime_handoff::classify_user_turn_prompt(message), |
| 132 | crate::runtime_handoff::UserTurnPromptKind::NotPrompt |
| 133 | ) |
| 134 | }) { |
| 135 | sync.messages.truncate(index); |
| 136 | } |
| 137 | DebugConversationUndo { removed, sync } |
| 138 | } |
| 139 | |
| 140 | fn session_sync_payload(app: &App) -> SessionSyncPayload { |
| 141 | SessionSyncPayload { |
| 142 | session_id: app.current_session_id.clone(), |
| 143 | messages: app.api_messages.as_ref().clone(), |
| 144 | system_prompt: app.system_prompt.clone(), |
| 145 | model: app.model.clone(), |
| 146 | workspace: app.workspace.clone(), |
| 147 | mode: super::to_command_mode(app.mode), |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | pub(crate) fn prune_undone_tool_context(app: &mut App, tool_id: &str) { |
| 152 | // A display/control id alone must not choose between duplicated or mixed |
| 153 | // legacy/local history. Refuse to prune when the source is ambiguous. |
| 154 | let mut matches = app |
| 155 | .api_messages |
| 156 | .iter() |
| 157 | .enumerate() |
| 158 | .flat_map(|(msg_idx, msg)| { |
| 159 | msg.content |
| 160 | .iter() |
| 161 | .enumerate() |
| 162 | .filter_map(move |(block_idx, block)| { |
| 163 | (matches!(block, ContentBlock::ToolUse { .. }) |
| 164 | && block.tool_call_key().is_some_and(|key| { |
| 165 | !key.as_str().trim().is_empty() && key.as_str() == tool_id |
| 166 | })) |
| 167 | .then_some((msg_idx, block_idx)) |
| 168 | }) |
| 169 | }); |
| 170 | let Some((msg_idx, block_idx)) = matches.next() else { |
| 171 | return; |
| 172 | }; |
| 173 | if matches.next().is_some() { |
| 174 | return; |
| 175 | } |
| 176 | drop(matches); |
| 177 | if let Some(history_idx) = app.tool_cells.get(tool_id).copied() { |
| 178 | app.truncate_history_to(history_idx); |
| 179 | } |
| 180 | let kept_blocks = app.api_messages[msg_idx].content[..block_idx].to_vec(); |
| 181 | let kept_tool_ids: std::collections::HashSet<_> = kept_blocks |
| 182 | .iter() |
| 183 | .filter_map(|block| match block { |
| 184 | ContentBlock::ToolUse { id, .. } => block.tool_call_key().map(|key| (key, id.as_str())), |
| 185 | _ => None, |
| 186 | }) |
| 187 | .collect(); |
| 188 | if kept_blocks.is_empty() { |
| 189 | app.truncate_api_messages(msg_idx); |
| 190 | return; |
| 191 | } |
| 192 | // Preserve surviving result blocks even when a message also contains the |
| 193 | // undone result; retain the stamp of the original message. |
| 194 | let preserved_tool_results: Vec<_> = app |
| 195 | .api_messages_stamped() |
| 196 | .skip(msg_idx + 1) |
| 197 | .take_while(|(msg, _)| { |
| 198 | msg.role == "user" |
| 199 | && !msg.content.is_empty() |
| 200 | && msg |
| 201 | .content |
| 202 | .iter() |
| 203 | .all(|block| tool_result_id(block).is_some()) |
| 204 | }) |
| 205 | .filter_map(|(msg, stamp)| { |
| 206 | let mut retained = msg.clone(); |
| 207 | retained.content.retain(|block| { |
| 208 | tool_result_id(block).is_some_and(|key| kept_tool_ids.contains(&key)) |
| 209 | }); |
| 210 | (!retained.content.is_empty()).then_some((retained, stamp)) |
| 211 | }) |
| 212 | .collect(); |
| 213 | app.truncate_api_messages(msg_idx + 1); |
| 214 | app.api_messages_mut()[msg_idx].content = kept_blocks; |
| 215 | for (message, stamp) in preserved_tool_results { |
| 216 | app.push_api_message_stamped(message, stamp); |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | fn prune_undone_turn_context(app: &mut App) { |
| 221 | if let Some(history_idx) = app |
| 222 | .history |
| 223 | .iter() |
| 224 | .rposition(|cell| matches!(cell, HistoryCell::User { .. })) |
| 225 | { |
| 226 | app.truncate_history_to(history_idx); |
| 227 | } |
| 228 | |
| 229 | if let Some(api_idx) = app.api_messages.iter().rposition(|msg| msg.role == "user") { |
| 230 | app.truncate_api_messages(api_idx); |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | fn tool_result_id(block: &ContentBlock) -> Option<(codewhale_models::ToolCallKey<'_>, &str)> { |
| 235 | match block { |
| 236 | ContentBlock::ToolResult { tool_use_id, .. } |
| 237 | | ContentBlock::ToolSearchToolResult { tool_use_id, .. } |
| 238 | | ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => { |
| 239 | block.tool_call_key().map(|key| (key, tool_use_id.as_str())) |
| 240 | } |
| 241 | _ => None, |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | /// Deepest fork chain [`snapshot_owners`] follows. A chain this long is |
| 246 | /// already unusual; the bound only stops a corrupt lineage from looping. |
| 247 | const MAX_FORK_ANCESTORS: usize = 32; |
| 248 | |
| 249 | /// A session whose restore points this conversation owns. |
| 250 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 251 | pub(in crate::commands) struct SnapshotOwner { |
| 252 | /// Session tag the snapshots carry. |
| 253 | pub(in crate::commands) session_id: String, |
| 254 | /// Newest snapshot time (Unix seconds) owned from this session: `None` |
| 255 | /// for the current session, the fork time for a session it was forked |
| 256 | /// from. The source keeps working after the fork, and its later |
| 257 | /// snapshots are not the fork's. |
| 258 | pub(in crate::commands) until: Option<i64>, |
| 259 | } |
| 260 | |
| 261 | impl SnapshotOwner { |
| 262 | fn owns(&self, snapshot: &crate::snapshot::Snapshot) -> bool { |
| 263 | snapshot.session_id.as_deref() == Some(self.session_id.as_str()) |
| 264 | && self.until.is_none_or(|until| snapshot.timestamp <= until) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | /// The sessions whose restore points `/undo` may use: the current session, |
| 269 | /// and for a fork each session it was forked from, up to the fork. A fork |
| 270 | /// copies its source's turns, so the snapshots those turns took (tagged |
| 271 | /// with the source's id) are the fork's too, as the Runtime's thread-owned |
| 272 | /// restore points are (#6621). |
| 273 | /// |
| 274 | /// Lineage the saved sessions cannot prove ends the chain: fewer owners |
| 275 | /// means fewer restorable steps, never someone else's. |
| 276 | pub(in crate::commands) fn snapshot_owners(app: &App) -> Vec<SnapshotOwner> { |
| 277 | let Some(current) = app.current_session_id.clone() else { |
| 278 | return Vec::new(); |
| 279 | }; |
| 280 | let manager = crate::session_manager::SessionManager::default_location().ok(); |
| 281 | let load = |id: &str| { |
| 282 | manager |
| 283 | .as_ref() |
| 284 | .and_then(|manager| manager.load_session_metadata_by_id(id).ok()) |
| 285 | }; |
| 286 | let mut metadata = app |
| 287 | .current_session_metadata |
| 288 | .clone() |
| 289 | .filter(|metadata| metadata.id == current) |
| 290 | .or_else(|| load(¤t)); |
| 291 | let mut owners = vec![SnapshotOwner { |
| 292 | session_id: current, |
| 293 | until: None, |
| 294 | }]; |
| 295 | while let Some(child) = metadata.take() { |
| 296 | let Some(parent) = child.parent_session_id.clone() else { |
| 297 | break; |
| 298 | }; |
| 299 | if owners.len() > MAX_FORK_ANCESTORS |
| 300 | || owners.iter().any(|owner| owner.session_id == parent) |
| 301 | { |
| 302 | break; |
| 303 | } |
| 304 | let forked_at = child.created_at.timestamp(); |
| 305 | let until = owners |
| 306 | .last() |
| 307 | .and_then(|owner| owner.until) |
| 308 | .map_or(forked_at, |child_until| child_until.min(forked_at)); |
| 309 | metadata = load(&parent); |
| 310 | owners.push(SnapshotOwner { |
| 311 | session_id: parent, |
| 312 | until: Some(until), |
| 313 | }); |
| 314 | } |
| 315 | owners |
| 316 | } |
| 317 | |
| 318 | /// Labels a `/undo` step starts at: before one tool call, or before a turn. |
| 319 | fn is_undo_step_label(label: &str) -> bool { |
| 320 | label.starts_with("tool:") || label.starts_with("pre-turn:") |
| 321 | } |
| 322 | |
| 323 | /// Labels of the restore points an engine takes for a turn. A step runs from |
| 324 | /// one of them to the next one the conversation owns. |
| 325 | fn is_restore_point_label(label: &str) -> bool { |
| 326 | is_undo_step_label(label) || label.starts_with("post-tool:") || label.starts_with("post-turn:") |
| 327 | } |
| 328 | |
| 329 | /// One `/undo` step, planned but not applied. |
| 330 | pub(in crate::commands) struct UndoStep { |
| 331 | /// Restore point the step started at. |
| 332 | pub(in crate::commands) target: crate::snapshot::Snapshot, |
| 333 | /// Tree the step ended at: the next restore point this conversation |
| 334 | /// owns, or, for the newest step, a snapshot of the workspace as it is |
| 335 | /// now. Trees, not commit ids, because a prune rewrites commit ids. |
| 336 | pub(in crate::commands) end: crate::snapshot::SnapshotId, |
| 337 | /// The paths the step changed that are still as it left them. |
| 338 | pub(in crate::commands) restore: Vec<PathBuf>, |
| 339 | /// Changed paths `/undo` leaves in place because they are not regular |
| 340 | /// files (a symlink, a directory, a submodule), in this step or in a |
| 341 | /// newer one it walked past. |
| 342 | pub(in crate::commands) skipped: Vec<PathBuf>, |
| 343 | /// The `pre-restore:` snapshot planning took of the workspace, when the |
| 344 | /// step ends now; the restore reuses it as its safety backup. |
| 345 | pub(in crate::commands) backup: Option<crate::snapshot::SnapshotId>, |
| 346 | } |
| 347 | |
| 348 | /// Find the newest step of `snapshots` (newest first) that `owners` own and |
| 349 | /// that is not undone yet, and the paths undoing it restores. |
| 350 | /// |
| 351 | /// A step is scoped to the paths that changed between its restore point and |
| 352 | /// the next one: edits to any other file (the user's, another session's) |
| 353 | /// are never touched. A path the step changed that changed again since is |
| 354 | /// refused rather than overwritten. A step whose paths are all back at its |
| 355 | /// restore point is already undone, so `/undo` walks back one tool call (or |
| 356 | /// turn) at a time (#384). A changed path that is not a regular file is |
| 357 | /// left in place and reported (file-scoped restore never writes symlinks or |
| 358 | /// directories); it does not block the step's other paths or older steps. |
| 359 | /// |
| 360 | /// Planning writes nothing, except when the newest step ends now: the |
| 361 | /// workspace is then snapshotted, and only when `trusted`, since `/undo` |
| 362 | /// outside trusted mode refuses to touch files anyway. |
| 363 | /// |
| 364 | /// Known limits: the TUI records no per-tool receipts (the Runtime's |
| 365 | /// `post-tool:` spans and declared write paths), so a step owns everything |
| 366 | /// that changed between its restore point and the next one this |
| 367 | /// conversation owns, including a write another session made in that window. |
| 368 | /// The newest step, when no later restore point exists yet (the turn is still |
| 369 | /// running, or its post-turn snapshot failed), ends at the workspace as it |
| 370 | /// is now, so an edit made since the step's restore point counts as the |
| 371 | /// step's. [`undo_files`] first waits for a post-turn snapshot this process |
| 372 | /// is still taking, so this is not the case right after a turn. |
| 373 | // The planner is host-internal. Box only its uncommon early outcome to keep |
| 374 | // Result small; the public facet still returns an owned data-only value. |
| 375 | fn plan_undo_step( |
| 376 | repo: &crate::snapshot::SnapshotRepo, |
| 377 | snapshots: Vec<crate::snapshot::Snapshot>, |
| 378 | owners: &[SnapshotOwner], |
| 379 | trusted: bool, |
| 380 | ) -> Result<UndoStep, Box<DebugUndoOutcome>> { |
| 381 | let owned: Vec<crate::snapshot::Snapshot> = snapshots |
| 382 | .into_iter() |
| 383 | .filter(|snapshot| is_restore_point_label(&snapshot.label)) |
| 384 | .filter(|snapshot| owners.iter().any(|owner| owner.owns(snapshot))) |
| 385 | .collect(); |
| 386 | if !owned |
| 387 | .iter() |
| 388 | .any(|snapshot| is_undo_step_label(&snapshot.label)) |
| 389 | { |
| 390 | return Err(Box::new(DebugUndoOutcome::NoOwnedSteps)); |
| 391 | } |
| 392 | |
| 393 | let compare_failed = |error: std::io::Error| DebugUndoOutcome::CompareFailed(error.to_string()); |
| 394 | // `InvalidInput` from a path comparison: the path is not a regular file |
| 395 | // (or not a safe workspace path) on one side, so it is left alone. |
| 396 | let unrestorable = |error: &std::io::Error| error.kind() == std::io::ErrorKind::InvalidInput; |
| 397 | |
| 398 | let mut skipped: Vec<PathBuf> = Vec::new(); |
| 399 | for (index, target) in owned.iter().enumerate() { |
| 400 | if !is_undo_step_label(&target.label) { |
| 401 | continue; |
| 402 | } |
| 403 | let mut backup = None; |
| 404 | let end = match index.checked_sub(1) { |
| 405 | Some(newer) => owned[newer].tree.clone(), |
| 406 | // The newest step has no later restore point (the turn is still |
| 407 | // running, stopped early, or its post-turn snapshot has not |
| 408 | // landed): the workspace now is the only record of its end. |
| 409 | None => { |
| 410 | if repo |
| 411 | .work_tree_matches_snapshot(&target.tree) |
| 412 | .map_err(compare_failed)? |
| 413 | { |
| 414 | continue; |
| 415 | } |
| 416 | if !trusted { |
| 417 | return Err(Box::new(DebugUndoOutcome::Untrusted)); |
| 418 | } |
| 419 | let short = &target.id.as_str()[..target.id.as_str().len().min(12)]; |
| 420 | let taken = repo |
| 421 | .take_snapshot(&format!("pre-restore:{short}"), None) |
| 422 | .map_err(|error| DebugUndoOutcome::SnapshotFailed(error.to_string()))?; |
| 423 | backup = Some(taken.id); |
| 424 | taken.tree |
| 425 | } |
| 426 | }; |
| 427 | let changed = repo |
| 428 | .changed_paths_between(&target.tree, &end) |
| 429 | .map_err(compare_failed)?; |
| 430 | let mut restore = Vec::new(); |
| 431 | let mut changed_since = Vec::new(); |
| 432 | 'paths: for path in changed { |
| 433 | match repo.path_matches_snapshot(&end, &path) { |
| 434 | // Still as the step left it. Comparing the step's start too |
| 435 | // proves it holds a regular file (or nothing) to restore. |
| 436 | Ok(true) => match repo.path_same_in_snapshots(&target.tree, &end, &path) { |
| 437 | Ok(_) => { |
| 438 | restore.push(path); |
| 439 | continue; |
| 440 | } |
| 441 | Err(error) if unrestorable(&error) => { |
| 442 | skipped.push(path); |
| 443 | continue; |
| 444 | } |
| 445 | Err(error) => return Err(Box::new(compare_failed(error))), |
| 446 | }, |
| 447 | Ok(false) => {} |
| 448 | Err(error) if unrestorable(&error) => { |
| 449 | skipped.push(path); |
| 450 | continue; |
| 451 | } |
| 452 | Err(error) => return Err(Box::new(compare_failed(error))), |
| 453 | } |
| 454 | // Back at the step's start, or at an older restore point that an |
| 455 | // earlier `/undo` walked it back to: already undone. |
| 456 | for older in &owned[index..] { |
| 457 | match repo.path_matches_snapshot(&older.tree, &path) { |
| 458 | Ok(true) => continue 'paths, |
| 459 | Ok(false) => {} |
| 460 | Err(error) if unrestorable(&error) => { |
| 461 | skipped.push(path); |
| 462 | continue 'paths; |
| 463 | } |
| 464 | Err(error) => return Err(Box::new(compare_failed(error))), |
| 465 | } |
| 466 | } |
| 467 | changed_since.push(path.display().to_string()); |
| 468 | } |
| 469 | if !changed_since.is_empty() { |
| 470 | return Err(Box::new(DebugUndoOutcome::ChangedSince { |
| 471 | label: target.label.clone(), |
| 472 | paths: changed_since, |
| 473 | })); |
| 474 | } |
| 475 | if restore.is_empty() { |
| 476 | // Already undone, changed nothing, or changed only paths `/undo` |
| 477 | // cannot restore: keep walking back. |
| 478 | continue; |
| 479 | } |
| 480 | skipped.sort(); |
| 481 | skipped.dedup(); |
| 482 | return Ok(UndoStep { |
| 483 | target: target.clone(), |
| 484 | end, |
| 485 | restore, |
| 486 | skipped, |
| 487 | backup, |
| 488 | }); |
| 489 | } |
| 490 | Err(Box::new(DebugUndoOutcome::NoDifference)) |
| 491 | } |
| 492 | |
| 493 | /// How long `/undo` waits for a post-turn snapshot still being written. |
| 494 | const POST_TURN_SNAPSHOT_WAIT: std::time::Duration = std::time::Duration::from_secs(10); |
| 495 | |
| 496 | /// Revert the most recent write tool (apply_patch/edit_file/write_file) or turn. |
| 497 | /// |
| 498 | /// Opens the side-git snapshot repo and finds the newest `tool:*` or |
| 499 | /// `pre-turn:*` restore point this conversation owns (see |
| 500 | /// [`snapshot_owners`]) whose step is not undone yet, then restores only the |
| 501 | /// files that step changed (see [`plan_undo_step`]). Falls back to |
| 502 | /// conversation undo when no snapshots exist. |
| 503 | /// |
| 504 | /// Posts a `HistoryCell::System` entry so the user can see what was |
| 505 | /// reverted in the transcript. |
| 506 | /// Why workspace files may not be rolled back right now, if they may not. |
| 507 | /// |
| 508 | /// A running turn is reading and writing this workspace: restoring files |
| 509 | /// under it discards the turn's in-flight work and leaves the model's view of |
| 510 | /// the files wrong. `/undo` and `/restore` refuse while one is active, like |
| 511 | /// the Runtime's restore routes. |
| 512 | pub(in crate::commands) fn active_turn_restore_refusal(app: &App) -> Option<String> { |
| 513 | let turn_active = app.is_loading |
| 514 | || app.is_compacting |
| 515 | || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")); |
| 516 | turn_active.then(|| { |
| 517 | "A turn is still running in this workspace, so files were not restored and nothing was changed. Wait for it to finish, or press Esc to stop it, then run the command again." |
| 518 | .to_string() |
| 519 | }) |
| 520 | } |
| 521 | |
| 522 | pub(in crate::commands) fn undo_files(app: &mut App) -> DebugUndoOutcome { |
| 523 | if let Some(refusal) = active_turn_restore_refusal(app) { |
| 524 | return DebugUndoOutcome::RestoreBlocked(refusal); |
| 525 | } |
| 526 | let workspace = app.workspace.clone(); |
| 527 | |
| 528 | let repo = match crate::snapshot::SnapshotRepo::open_or_init(&workspace) { |
| 529 | Ok(r) => r, |
| 530 | Err(e) => { |
| 531 | return DebugUndoOutcome::RepoUnavailable { |
| 532 | workspace, |
| 533 | error: e.to_string(), |
| 534 | }; |
| 535 | } |
| 536 | }; |
| 537 | |
| 538 | // A post-turn snapshot this process is still taking is the newest step's |
| 539 | // end: without it, every edit since the step's restore point would count |
| 540 | // as the step's. |
| 541 | if !crate::snapshot::wait_for_pending_post_turn_snapshots(POST_TURN_SNAPSHOT_WAIT) { |
| 542 | return DebugUndoOutcome::SnapshotPending; |
| 543 | } |
| 544 | |
| 545 | // The whole store: an older restore point that is still stored must not |
| 546 | // be mistaken for a pruned one. |
| 547 | let snapshots = match repo.list(usize::MAX) { |
| 548 | Ok(s) => s, |
| 549 | Err(e) => { |
| 550 | return DebugUndoOutcome::ListFailed(e.to_string()); |
| 551 | } |
| 552 | }; |
| 553 | |
| 554 | if snapshots.is_empty() { |
| 555 | return DebugUndoOutcome::NoSnapshots; |
| 556 | } |
| 557 | |
| 558 | // Automatic file rollback is allowed only when ownership is provable. |
| 559 | // Untagged legacy snapshots and snapshots from another conversation may |
| 560 | // describe unrelated user work in this same workspace, so fail closed |
| 561 | // and let the command dispatcher fall back to conversation-only undo. |
| 562 | let owners = snapshot_owners(app); |
| 563 | if owners.is_empty() { |
| 564 | return DebugUndoOutcome::NoSession; |
| 565 | } |
| 566 | |
| 567 | // Restoring workspace files is a mutation. Apply the trust gate only |
| 568 | // after finding a real, owned step so chat-only `/undo` can still fall |
| 569 | // back to conversation history in ordinary mode; planning itself writes |
| 570 | // nothing outside trusted mode. |
| 571 | let trusted = app.yolo || app.trust_mode; |
| 572 | let step = match plan_undo_step(&repo, snapshots, &owners, trusted) { |
| 573 | Ok(step) => step, |
| 574 | Err(outcome) => return *outcome, |
| 575 | }; |
| 576 | let target = &step.target; |
| 577 | if !trusted { |
| 578 | return DebugUndoOutcome::Untrusted; |
| 579 | } |
| 580 | |
| 581 | let plan: Vec<(PathBuf, crate::snapshot::SnapshotId)> = step |
| 582 | .restore |
| 583 | .iter() |
| 584 | .map(|path| (path.clone(), target.tree.clone())) |
| 585 | .collect(); |
| 586 | // Re-verify after the safety snapshot, immediately before the first |
| 587 | // write: a change that landed meanwhile is refused. |
| 588 | let preflight = || { |
| 589 | for path in &step.restore { |
| 590 | if !repo.path_matches_snapshot(&step.end, path)? { |
| 591 | return Err(std::io::Error::new( |
| 592 | std::io::ErrorKind::WouldBlock, |
| 593 | format!( |
| 594 | "'{}' changed while the undo was being prepared; nothing was changed.", |
| 595 | path.display() |
| 596 | ), |
| 597 | )); |
| 598 | } |
| 599 | } |
| 600 | Ok(()) |
| 601 | }; |
| 602 | let restored = match &step.backup { |
| 603 | // Planning already snapshotted the workspace (and `preflight` proves |
| 604 | // every planned path is still as that snapshot holds it). |
| 605 | Some(backup) => repo.restore_path_plan_with_backup(&plan, backup, true, preflight), |
| 606 | None => { |
| 607 | let backup_short = &target.id.as_str()[..target.id.as_str().len().min(12)]; |
| 608 | repo.restore_path_plan( |
| 609 | &plan, |
| 610 | &format!("pre-restore:{backup_short}"), |
| 611 | true, |
| 612 | preflight, |
| 613 | ) |
| 614 | } |
| 615 | }; |
| 616 | let outcomes = match restored { |
| 617 | Ok(outcomes) => outcomes, |
| 618 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { |
| 619 | return DebugUndoOutcome::RestoreBlocked(e.to_string()); |
| 620 | } |
| 621 | Err(e) => return DebugUndoOutcome::RestoreFailed(e.to_string()), |
| 622 | }; |
| 623 | |
| 624 | if let Some(tool_id) = target.label.strip_prefix("tool:") { |
| 625 | prune_undone_tool_context(app, tool_id); |
| 626 | } else if target.label.starts_with("pre-turn:") { |
| 627 | prune_undone_turn_context(app); |
| 628 | } |
| 629 | |
| 630 | let short = &target.id.as_str()[..target.id.as_str().len().min(8)]; |
| 631 | // Post a system cell so the reverted state is visible in the transcript. |
| 632 | app.push_history_cell(HistoryCell::System { |
| 633 | content: format!( |
| 634 | "/undo reverted workspace files to snapshot '{}' ({})", |
| 635 | target.label, short |
| 636 | ), |
| 637 | }); |
| 638 | |
| 639 | DebugUndoOutcome::Restored(DebugUndoRestored { |
| 640 | label: target.label.clone(), |
| 641 | snapshot_id: target.id.as_str().to_string(), |
| 642 | files: outcomes |
| 643 | .into_iter() |
| 644 | .map(|outcome| DebugRestoredFile { |
| 645 | path: outcome.path, |
| 646 | action: match outcome.action { |
| 647 | crate::snapshot::PathRestoreAction::Modified => DebugRestoreAction::Modified, |
| 648 | crate::snapshot::PathRestoreAction::Recreated => DebugRestoreAction::Recreated, |
| 649 | crate::snapshot::PathRestoreAction::Removed => DebugRestoreAction::Removed, |
| 650 | }, |
| 651 | }) |
| 652 | .collect(), |
| 653 | skipped: step.skipped, |
| 654 | sync: session_sync_payload(app), |
| 655 | }) |
| 656 | } |
| 657 |