返回 CodeWhale
mod.rs
根目录 / crates / tui / src / snapshot / mod.rs
1 //! Workspace snapshots — pre/post-turn safety net.
2 //!
3 //! Each turn the engine takes a `pre-turn:<seq>` snapshot of the user's
4 //! workspace into a side git repo at
5 //! `<snapshot state dir>/<project_hash>/<worktree_hash>/.git`, then a
6 //! matching `post-turn:<seq>` snapshot when the turn finishes. Users
7 //! can roll back via `/restore N` (slash command) or, when the model
8 //! recognises an "undo my last edit" intent, the `revert_turn` tool.
9 //!
10 //! ## Why a side repo?
11 //!
12 //! - The user's own `.git` is never touched. `--git-dir` and
13 //! `--work-tree` are *always* set together when we shell out to git;
14 //! that single invariant is what keeps snapshots and the user's repo
15 //! completely independent.
16 //! - Workspaces without git still get snapshots.
17 //! - `git`'s own deduplication (object packfiles) keeps the disk
18 //! footprint tractable — typical 100 MB workspace × 12 turns ≈ 1.2 GB
19 //! uncompressed but git's content-addressed storage usually brings
20 //! that down 10-30×. We mitigate further with:
21 //! - 7-day default retention (`session_manager` prunes at session
22 //! start via [`prune::prune_older_than`]).
23 //! - `gc.auto = 0` on the side repo (we don't want background gcs
24 //! firing mid-turn) plus an explicit `git gc --prune=now` after
25 //! prune.
26 //! - Startup cleanup for stale `tmp_pack_*` files left by interrupted
27 //! git pack operations.
28 //!
29 //! ## Failure model
30 //!
31 //! Pre/post-turn snapshot calls are **non-fatal**. If `git` is missing,
32 //! the disk is full, or the workspace is on a read-only filesystem, the
33 //! turn proceeds and the engine logs a warning. The snapshot is a
34 //! safety net, not a correctness gate.
35 //!
36 //! Workspaces over the configured size cap (`[snapshots] max_workspace_gb`,
37 //! default 2 GB of non-excluded content) skip snapshot init entirely. That
38 //! disable is intentionally loud: the operator is told once that undo is off
39 //! for the workspace, with the opt-in knobs (raise the cap, or set
40 //! `max_workspace_gb = 0` to disable the size gate). Scoped snapshot roots are
41 //! not yet a first-class config; the practical opt-in today is the cap override.
42
43 pub mod delta;
44 pub mod paths;
45 pub mod prune;
46 pub mod repo;
47
48 #[allow(unused_imports)]
49 pub use paths::{snapshot_dir_for, snapshot_git_dir};
50 pub use prune::{DEFAULT_MAX_AGE, prune_older_than};
51
52 /// Snapshots kept per workspace side-repo, pruned after each new snapshot to
53 /// cap disk usage (#1112): the newest this many, plus the newest this many
54 /// turn boundaries (`pre-turn:` / `post-turn:`), so a burst of per-tool
55 /// snapshots can never push out the restore points of the turns that took
56 /// them (see [`SnapshotRepo::prune_keep_last_n`]).
57 pub const DEFAULT_MAX_SNAPSHOTS: usize = 50;
58 pub use delta::{DeltaChange, SnapshotDelta};
59 #[allow(unused_imports)]
60 pub use repo::{
61 DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT, GATE_TOO_LARGE_MARKER, GATE_TOO_MANY_ENTRIES_MARKER,
62 GATE_UNSAFE_LOCATION_MARKER, PathRestoreAction, PathRestoreOutcome, SIZE_WALK_MAX_ENTRIES,
63 Snapshot, SnapshotId, SnapshotPathChange, SnapshotRepo, TakenSnapshot, WorkspaceGate,
64 estimate_workspace_size_bounded, is_git_metadata_name, workspace_relative_path,
65 };
66
67 /// Which point of a turn a recorded workspace snapshot captured.
68 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69 #[serde(rename_all = "snake_case")]
70 pub enum WorkspaceSnapshotKind {
71 /// Before the turn touched anything (`pre-turn:` label).
72 PreTurn,
73 /// Before one file-modifying tool call ran (`tool:<call_id>` label).
74 Tool,
75 /// After a tool call that had a `tool` snapshot finished
76 /// (`post-tool:<call_id>` label). Taken only by hosts that record
77 /// restore points, so the span a tool ran in is bounded on both sides.
78 PostTool,
79 /// After the turn finished (`post-turn:` label).
80 PostTurn,
81 }
82
83 impl WorkspaceSnapshotKind {
84 /// The label prefix the snapshot repo stores for this kind.
85 pub fn label_prefix(self) -> &'static str {
86 match self {
87 Self::PreTurn => "pre-turn:",
88 Self::Tool => "tool:",
89 Self::PostTool => "post-tool:",
90 Self::PostTurn => "post-turn:",
91 }
92 }
93 }
94
95 /// Receipt for one workspace snapshot an engine took on behalf of a turn.
96 ///
97 /// The engine reports it (`Event::WorkspaceSnapshotTaken`) and the Runtime
98 /// records it on the turn that was running, so a thread owns exactly the
99 /// restore points recorded on its own turns — including the turns a fork
100 /// inherited — regardless of which saved-session document the thread is
101 /// bound to. `tree_id` is the durable identity: a prune rebuilds the side
102 /// repo's commit chain and rewrites every commit id, but re-commits the same
103 /// trees. `session_id` is the tag the snapshot was taken under; a restore
104 /// point only resolves to a snapshot that still carries it.
105 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106 pub struct WorkspaceSnapshotRef {
107 pub kind: WorkspaceSnapshotKind,
108 /// Commit id when the snapshot was taken. A later prune may rewrite it;
109 /// `tree_id` still resolves the snapshot then.
110 pub snapshot_id: String,
111 /// Root tree of the snapshot.
112 pub tree_id: String,
113 /// Session tag the snapshot was taken under.
114 pub session_id: String,
115 /// The tool call that runs from this snapshot to the next one of the
116 /// turn: the call a `tool` snapshot preceded, or, on a `pre_turn`
117 /// snapshot, the user shell command a shell turn runs. A `post_tool`
118 /// snapshot names the call it closes.
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub tool_call_id: Option<String>,
121 /// For a `tool` snapshot of a file tool (`write_file`, `edit_file`,
122 /// `apply_patch`): the paths the call declared it writes, as it named
123 /// them. Absent for a tool whose writes are not declared (a shell
124 /// command, a program), which may change any path.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub write_paths: Option<Vec<String>>,
127 /// Workspace-relative paths whose content changed since the turn's
128 /// previous snapshot: what happened in the span this snapshot closes.
129 /// Absent on a `pre_turn` snapshot (nothing precedes it in the turn) and
130 /// when it could not be computed (the previous snapshot failed or is
131 /// gone), which leaves that span unaccounted for.
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub changed_paths: Option<Vec<String>>,
134 }
135
136 impl WorkspaceSnapshotRef {
137 pub fn new(
138 kind: WorkspaceSnapshotKind,
139 taken: &TakenSnapshot,
140 session_id: &str,
141 tool_call_id: Option<&str>,
142 ) -> Self {
143 Self {
144 kind,
145 snapshot_id: taken.id.as_str().to_string(),
146 tree_id: taken.tree.as_str().to_string(),
147 session_id: session_id.to_string(),
148 tool_call_id: tool_call_id.map(str::to_string),
149 write_paths: None,
150 changed_paths: None,
151 }
152 }
153
154 /// Whether `snapshot` (a row of [`SnapshotRepo::list`]) is this restore
155 /// point: same tree, same session tag, same label kind.
156 pub fn matches(&self, snapshot: &Snapshot) -> bool {
157 snapshot.tree.as_str() == self.tree_id
158 && snapshot.session_id.as_deref() == Some(self.session_id.as_str())
159 && snapshot.label.starts_with(self.kind.label_prefix())
160 }
161 }
162
163 /// Post-turn snapshots this process has promised but not written yet.
164 ///
165 /// An interactive engine takes its post-turn snapshot after `TurnComplete`,
166 /// off the input path (#234), so `/undo` typed right after a turn can run
167 /// before it lands. Without it the newest step would end at the workspace as
168 /// it is now, taking every edit made since the turn's last restore point for
169 /// the step's own, and both `git add -A` runs would race on the side repo's
170 /// index (#6644). The engine reserves the snapshot before `TurnComplete` and
171 /// `/undo` waits for it with [`wait_for_pending_post_turn_snapshots`].
172 ///
173 /// In-process only: a post-turn snapshot another process is taking is not
174 /// seen here.
175 static PENDING_POST_TURN: (std::sync::Mutex<usize>, std::sync::Condvar) =
176 (std::sync::Mutex::new(0), std::sync::Condvar::new());
177
178 /// A reserved post-turn snapshot; dropping it (after the snapshot is written,
179 /// failed, or abandoned) releases the reservation.
180 #[must_use = "the reservation is released when this is dropped"]
181 pub struct PendingPostTurnSnapshot(());
182
183 impl PendingPostTurnSnapshot {
184 pub fn reserve() -> Self {
185 let (count, _) = &PENDING_POST_TURN;
186 *count
187 .lock()
188 .unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
189 Self(())
190 }
191 }
192
193 impl Drop for PendingPostTurnSnapshot {
194 fn drop(&mut self) {
195 let (count, released) = &PENDING_POST_TURN;
196 let mut count = count
197 .lock()
198 .unwrap_or_else(std::sync::PoisonError::into_inner);
199 *count = count.saturating_sub(1);
200 released.notify_all();
201 }
202 }
203
204 /// Wait up to `timeout` for every reserved post-turn snapshot to be written.
205 /// Returns whether none is still pending.
206 pub fn wait_for_pending_post_turn_snapshots(timeout: std::time::Duration) -> bool {
207 let (count, released) = &PENDING_POST_TURN;
208 let count = count
209 .lock()
210 .unwrap_or_else(std::sync::PoisonError::into_inner);
211 let (count, _) = released
212 .wait_timeout_while(count, timeout, |pending| *pending > 0)
213 .unwrap_or_else(std::sync::PoisonError::into_inner);
214 *count == 0
215 }
216
216 lines RUST