返回 CodeWhale
debug_operations.rs
根目录 / crates / tui / src / commands / contract / debug_operations.rs
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(&current));
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
657 lines RUST