Skip to main content

winx_code_agent/state/
bash_state.rs

1#![allow(clippy::unwrap_used)]
2use anyhow::Result;
3use rand::RngExt;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::{HashMap, VecDeque};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use tokio::sync::Mutex;
10use tracing::info;
11
12use crate::state::persistence::{
13    delete_bash_state as delete_state_file, load_bash_state as load_state_file,
14    save_bash_state as save_state_file, BashStateSnapshot,
15};
16use crate::state::pty::PtyShell;
17use crate::types::{
18    AllowedCommands, AllowedGlobs, BashCommandMode, BashMode, FileEditMode, Modes, WriteIfEmptyMode,
19};
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FileWhitelistData {
23    pub file_hash: String,
24    pub line_ranges_read: Vec<(usize, usize)>,
25    pub total_lines: usize,
26}
27
28impl FileWhitelistData {
29    pub fn new(
30        file_hash: String,
31        line_ranges_read: Vec<(usize, usize)>,
32        total_lines: usize,
33    ) -> Self {
34        Self { file_hash, line_ranges_read, total_lines }
35    }
36
37    pub fn is_read_enough(&self) -> bool {
38        self.get_percentage_read() >= 99.0
39    }
40
41    pub fn get_percentage_read(&self) -> f64 {
42        if self.total_lines == 0 {
43            return 100.0;
44        }
45        let mut lines_read = std::collections::HashSet::new();
46        for (start, end) in &self.line_ranges_read {
47            for line in *start..=*end {
48                lines_read.insert(line);
49            }
50        }
51        (lines_read.len() as f64 / self.total_lines as f64) * 100.0
52    }
53
54    pub fn get_unread_ranges(&self) -> Vec<(usize, usize)> {
55        if self.total_lines == 0 {
56            return vec![];
57        }
58        let mut lines_read = std::collections::HashSet::new();
59        for (start, end) in &self.line_ranges_read {
60            for line in *start..=*end {
61                lines_read.insert(line);
62            }
63        }
64        let mut unread = vec![];
65        let mut start_range = None;
66        for i in 1..=self.total_lines {
67            if !lines_read.contains(&i) {
68                if start_range.is_none() {
69                    start_range = Some(i);
70                }
71            } else if let Some(start) = start_range {
72                unread.push((start, i - 1));
73                start_range = None;
74            }
75        }
76        if let Some(start) = start_range {
77            unread.push((start, self.total_lines));
78        }
79        unread
80    }
81
82    pub fn add_range(&mut self, start: usize, end: usize) {
83        self.line_ranges_read.push((start, end));
84    }
85
86    pub fn get_read_error_message(&self, file_path: &Path) -> String {
87        format!(
88            "File {} needs more reading. Coverage: {:.1}%",
89            file_path.display(),
90            self.get_percentage_read()
91        )
92    }
93
94    pub fn needs_more_reading(&self) -> bool {
95        !self.is_read_enough()
96    }
97}
98
99/// How many edit checkpoints to keep per session for `UndoEdit`. In-memory only
100/// (not persisted), oldest dropped past the cap, bounding memory on long sessions.
101const EDIT_CHECKPOINT_CAP: usize = 10;
102
103/// Largest prior-content a checkpoint will hold. Files (up to the 50 MB edit
104/// ceiling) above this aren't checkpointed, so a session editing huge assets
105/// can't pile up to ~CAP * 50 MB of undo snapshots in memory; those edits just
106/// aren't undoable.
107const EDIT_CHECKPOINT_MAX_CONTENT_BYTES: usize = 1_000_000;
108
109/// A single file's pre-edit state, captured by `FileWriteOrEdit`/`MultiFileEdit`
110/// after a successful write so `UndoEdit` can restore it. Only existing files get
111/// one (a brand-new file's creation is not undoable - there is no prior content).
112#[derive(Debug, Clone)]
113pub struct EditCheckpoint {
114    /// Resolved, workspace-confined path string (matches `whitelist_for_overwrite` keys).
115    pub file_path_str: String,
116    pub path: PathBuf,
117    /// File content before the edit, to be written back on undo.
118    pub prior_content: String,
119    /// The whitelist entry before the edit, restored on undo so the hash gate of a
120    /// later edit matches the reverted content. `None` if there was none.
121    pub prior_whitelist: Option<FileWhitelistData>,
122}
123
124#[derive(Debug, Clone)]
125pub struct BashState {
126    pub cwd: PathBuf,
127    pub workspace_root: PathBuf,
128    pub current_thread_id: String,
129    pub mode: Modes,
130    pub bash_command_mode: BashCommandMode,
131    pub file_edit_mode: FileEditMode,
132    pub write_if_empty_mode: WriteIfEmptyMode,
133    pub whitelist_for_overwrite: HashMap<String, FileWhitelistData>,
134    pub pty_shell: Arc<Mutex<Option<PtyShell>>>,
135    pub initialized: bool,
136    /// In-memory ring of recent edit checkpoints for `UndoEdit` (newest at the
137    /// back). Deliberately not part of `BashStateSnapshot`: undo is for immediate
138    /// mid-session recovery, not across restarts.
139    pub edit_checkpoints: VecDeque<EditCheckpoint>,
140}
141
142impl Default for BashState {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl BashState {
149    pub fn new() -> Self {
150        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
151        Self {
152            cwd: cwd.clone(),
153            workspace_root: cwd,
154            current_thread_id: generate_thread_id(),
155            mode: Modes::Wcgw,
156            bash_command_mode: BashCommandMode {
157                bash_mode: BashMode::NormalMode,
158                allowed_commands: AllowedCommands::All("all".to_string()),
159            },
160            file_edit_mode: FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
161            write_if_empty_mode: WriteIfEmptyMode {
162                allowed_globs: AllowedGlobs::All("all".to_string()),
163            },
164            whitelist_for_overwrite: HashMap::new(),
165            pty_shell: Arc::new(Mutex::new(None)),
166            initialized: false,
167            edit_checkpoints: VecDeque::new(),
168        }
169    }
170
171    /// Record a pre-edit checkpoint for `UndoEdit`, dropping the oldest past the
172    /// cap. Large files are skipped (not undoable) to bound memory.
173    pub fn push_edit_checkpoint(&mut self, checkpoint: EditCheckpoint) {
174        if checkpoint.prior_content.len() > EDIT_CHECKPOINT_MAX_CONTENT_BYTES {
175            info!(
176                file = %checkpoint.file_path_str,
177                "UndoEdit: not checkpointing a file over 1 MB (too large to hold in memory)"
178            );
179            return;
180        }
181        self.edit_checkpoints.push_back(checkpoint);
182        while self.edit_checkpoints.len() > EDIT_CHECKPOINT_CAP {
183            self.edit_checkpoints.pop_front();
184        }
185    }
186
187    /// Remove and return the most recent checkpoint for `file_path_str` (per-file
188    /// LIFO, so repeated undos on one file walk its edits back while leaving other
189    /// files' checkpoints in place). `None` if that file has no checkpoint.
190    pub fn pop_edit_checkpoint_for(&mut self, file_path_str: &str) -> Option<EditCheckpoint> {
191        let index =
192            self.edit_checkpoints.iter().rposition(|cp| cp.file_path_str == file_path_str)?;
193        self.edit_checkpoints.remove(index)
194    }
195
196    pub async fn init_pty_shell(&mut self) -> Result<()> {
197        let shell =
198            PtyShell::new(&self.cwd, self.bash_command_mode.bash_mode == BashMode::RestrictedMode)?;
199        *self.pty_shell.lock().await = Some(shell);
200        Ok(())
201    }
202
203    pub fn update_cwd(&mut self, path: &Path) -> Result<()> {
204        self.cwd = path.to_path_buf();
205        Ok(())
206    }
207
208    pub fn update_workspace_root(&mut self, path: &Path) -> Result<()> {
209        self.workspace_root = path.to_path_buf();
210        Ok(())
211    }
212
213    pub fn is_command_allowed(&self, command: &str) -> bool {
214        self.bash_command_mode.allowed_commands.is_allowed(command)
215    }
216
217    pub fn is_file_edit_allowed(&self, path: &str) -> bool {
218        self.file_edit_mode.allowed_globs.is_allowed(path)
219    }
220
221    pub fn is_file_write_allowed(&self, path: &str) -> bool {
222        self.write_if_empty_mode.allowed_globs.is_allowed(path)
223    }
224    pub fn get_mode_violation_message(&self, op: &str, _target: &str) -> String {
225        format!("Operation {op} not allowed")
226    }
227
228    pub fn save_state_to_disk(&self) -> Result<()> {
229        let snapshot = self.snapshot();
230        save_state_file(&self.current_thread_id, &snapshot)?;
231        Ok(())
232    }
233
234    pub fn snapshot(&self) -> BashStateSnapshot {
235        BashStateSnapshot::from_state(
236            &self.cwd.to_string_lossy(),
237            &self.workspace_root.to_string_lossy(),
238            &self.mode,
239            &self.bash_command_mode,
240            &self.file_edit_mode,
241            &self.write_if_empty_mode,
242            &self.whitelist_for_overwrite,
243            &self.current_thread_id,
244        )
245    }
246
247    pub fn apply_snapshot(&mut self, snapshot: &BashStateSnapshot) {
248        let (cwd, root, mode, bmode, emode, wmode, whitelist, tid) = snapshot.to_state_components();
249
250        self.cwd = PathBuf::from(cwd);
251        self.workspace_root = PathBuf::from(root);
252        self.mode = mode;
253        self.bash_command_mode = bmode;
254        self.file_edit_mode = emode;
255        self.write_if_empty_mode = wmode;
256        self.whitelist_for_overwrite = whitelist;
257        self.current_thread_id = tid;
258        self.initialized = true;
259    }
260
261    pub fn load_state_from_disk(&mut self, thread_id: &str) -> Result<bool> {
262        if let Some(snapshot) = load_state_file(thread_id)? {
263            self.apply_snapshot(&snapshot);
264            Ok(true)
265        } else {
266            Ok(false)
267        }
268    }
269
270    pub fn new_with_thread_id(thread_id: Option<&str>) -> Self {
271        let mut state = Self::new();
272
273        if let Some(tid) = thread_id {
274            if !tid.is_empty() {
275                if let Ok(true) = state.load_state_from_disk(tid) {
276                    info!("Loaded state for thread_id '{}'", tid);
277                } else {
278                    state.current_thread_id = tid.to_string();
279                }
280            }
281        }
282
283        state
284    }
285}
286
287pub fn generate_thread_id() -> String {
288    let mut rng = rand::rng();
289    format!("tid_{:x}", rng.random::<u64>())
290}