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
28/// Clamp ranges to `1..=total_lines`, drop inverted/empty ones, and sort. Shared
29/// by the coverage queries so both tolerate overlapping/out-of-bounds ranges.
30fn clamped_sorted(ranges: &[(usize, usize)], total_lines: usize) -> Vec<(usize, usize)> {
31    let mut v: Vec<(usize, usize)> = ranges
32        .iter()
33        .map(|&(s, e)| (s.max(1), e.min(total_lines)))
34        .filter(|&(s, e)| s <= e)
35        .collect();
36    v.sort_unstable();
37    v
38}
39
40/// Count distinct lines covered by (possibly overlapping) `ranges`, clamped to
41/// `1..=total_lines`, via a single sort+sweep — O(k log k) in the range count.
42fn covered_line_count(ranges: &[(usize, usize)], total_lines: usize) -> usize {
43    let mut covered = 0usize;
44    let mut last_end = 0usize; // highest line already counted (0 = none)
45    for (s, e) in clamped_sorted(ranges, total_lines) {
46        let s = s.max(last_end + 1);
47        if s <= e {
48            covered += e - s + 1;
49            last_end = e;
50        }
51    }
52    covered
53}
54
55impl FileWhitelistData {
56    pub fn new(
57        file_hash: String,
58        line_ranges_read: Vec<(usize, usize)>,
59        total_lines: usize,
60    ) -> Self {
61        let mut data = Self { file_hash, line_ranges_read: Vec::new(), total_lines };
62        data.merge_ranges(line_ranges_read);
63        data
64    }
65
66    pub fn is_read_enough(&self) -> bool {
67        self.get_percentage_read() >= 99.0
68    }
69
70    pub fn get_percentage_read(&self) -> f64 {
71        if self.total_lines == 0 {
72            return 100.0;
73        }
74        // Sort+sweep over the ranges (O(k log k) in the range count) instead of
75        // building an O(total_lines) HashSet on every call. Robust to overlapping
76        // or out-of-range entries from older un-merged snapshots.
77        let covered = covered_line_count(&self.line_ranges_read, self.total_lines);
78        (covered as f64 / self.total_lines as f64) * 100.0
79    }
80
81    pub fn get_unread_ranges(&self) -> Vec<(usize, usize)> {
82        if self.total_lines == 0 {
83            return vec![];
84        }
85        let sorted = clamped_sorted(&self.line_ranges_read, self.total_lines);
86        let mut unread = vec![];
87        let mut next = 1usize; // next line not yet known-read
88        for (s, e) in sorted {
89            if s > next {
90                unread.push((next, s - 1));
91            }
92            next = next.max(e.saturating_add(1));
93        }
94        if next <= self.total_lines {
95            unread.push((next, self.total_lines));
96        }
97        unread
98    }
99
100    /// Record `[start, end]` as read, merging it into the existing intervals so
101    /// `line_ranges_read` stays a bounded set of disjoint ranges. Without the
102    /// merge, re-reading a file appended duplicate ranges forever (unbounded
103    /// memory per session).
104    pub fn add_range(&mut self, start: usize, end: usize) {
105        self.merge_ranges(std::iter::once((start, end)));
106    }
107
108    /// Merge `new` ranges into `line_ranges_read`, keeping it sorted and disjoint
109    /// (adjacent inclusive ranges like `(1,3)` and `(4,5)` collapse to `(1,5)`).
110    pub fn merge_ranges(&mut self, new: impl IntoIterator<Item = (usize, usize)>) {
111        self.line_ranges_read.extend(new);
112        self.line_ranges_read.retain(|(s, e)| s <= e);
113        self.line_ranges_read.sort_unstable();
114        let mut merged: Vec<(usize, usize)> = Vec::with_capacity(self.line_ranges_read.len());
115        for (s, e) in self.line_ranges_read.drain(..) {
116            match merged.last_mut() {
117                Some(last) if s <= last.1.saturating_add(1) => last.1 = last.1.max(e),
118                _ => merged.push((s, e)),
119            }
120        }
121        self.line_ranges_read = merged;
122    }
123
124    pub fn needs_more_reading(&self) -> bool {
125        !self.is_read_enough()
126    }
127}
128
129/// How many edit checkpoints to keep per session for `UndoEdit`. In-memory only
130/// (not persisted), oldest dropped past the cap, bounding memory on long sessions.
131const EDIT_CHECKPOINT_CAP: usize = 10;
132
133/// Largest prior-content a checkpoint will hold. Files (up to the 50 MB edit
134/// ceiling) above this aren't checkpointed, so a session editing huge assets
135/// can't pile up to ~CAP * 50 MB of undo snapshots in memory; those edits just
136/// aren't undoable.
137const EDIT_CHECKPOINT_MAX_CONTENT_BYTES: usize = 1_000_000;
138
139/// A single file's pre-edit state, captured by `FileWriteOrEdit`/`MultiFileEdit`
140/// after a successful write so `UndoEdit` can restore it. Only existing files get
141/// one (a brand-new file's creation is not undoable - there is no prior content).
142#[derive(Debug, Clone)]
143pub struct EditCheckpoint {
144    /// Resolved, workspace-confined path string (matches `whitelist_for_overwrite` keys).
145    pub file_path_str: String,
146    pub path: PathBuf,
147    /// File content before the edit, to be written back on undo.
148    pub prior_content: String,
149    /// The whitelist entry before the edit, restored on undo so the hash gate of a
150    /// later edit matches the reverted content. `None` if there was none.
151    pub prior_whitelist: Option<FileWhitelistData>,
152}
153
154#[derive(Debug, Clone)]
155pub struct BashState {
156    pub cwd: PathBuf,
157    pub workspace_root: PathBuf,
158    pub current_thread_id: String,
159    pub mode: Modes,
160    pub bash_command_mode: BashCommandMode,
161    pub file_edit_mode: FileEditMode,
162    pub write_if_empty_mode: WriteIfEmptyMode,
163    pub whitelist_for_overwrite: HashMap<String, FileWhitelistData>,
164    pub pty_shell: Arc<Mutex<Option<PtyShell>>>,
165    /// Serializes foreground command startup across cloned request-local states.
166    /// Status/input actions remain concurrent so callers can drive a running TUI.
167    pub foreground_command_gate: Arc<Mutex<()>>,
168    pub initialized: bool,
169    /// In-memory ring of recent edit checkpoints for `UndoEdit` (newest at the
170    /// back). Deliberately not part of `BashStateSnapshot`: undo is for immediate
171    /// mid-session recovery, not across restarts.
172    pub edit_checkpoints: VecDeque<EditCheckpoint>,
173}
174
175impl Default for BashState {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181impl BashState {
182    pub fn new() -> Self {
183        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
184        Self {
185            cwd: cwd.clone(),
186            workspace_root: cwd,
187            current_thread_id: generate_thread_id(),
188            mode: Modes::Wcgw,
189            bash_command_mode: BashCommandMode {
190                bash_mode: BashMode::NormalMode,
191                allowed_commands: AllowedCommands::All("all".to_string()),
192            },
193            file_edit_mode: FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
194            write_if_empty_mode: WriteIfEmptyMode {
195                allowed_globs: AllowedGlobs::All("all".to_string()),
196            },
197            whitelist_for_overwrite: HashMap::new(),
198            pty_shell: Arc::new(Mutex::new(None)),
199            foreground_command_gate: Arc::new(Mutex::new(())),
200            initialized: false,
201            edit_checkpoints: VecDeque::new(),
202        }
203    }
204
205    /// Record a pre-edit checkpoint for `UndoEdit`, dropping the oldest past the
206    /// cap. Large files are skipped (not undoable) to bound memory.
207    pub fn push_edit_checkpoint(&mut self, checkpoint: EditCheckpoint) {
208        if checkpoint.prior_content.len() > EDIT_CHECKPOINT_MAX_CONTENT_BYTES {
209            info!(
210                file = %checkpoint.file_path_str,
211                "UndoEdit: not checkpointing a file over 1 MB (too large to hold in memory)"
212            );
213            return;
214        }
215        self.edit_checkpoints.push_back(checkpoint);
216        while self.edit_checkpoints.len() > EDIT_CHECKPOINT_CAP {
217            self.edit_checkpoints.pop_front();
218        }
219    }
220
221    /// Remove and return the most recent checkpoint for `file_path_str` (per-file
222    /// LIFO, so repeated undos on one file walk its edits back while leaving other
223    /// files' checkpoints in place). `None` if that file has no checkpoint.
224    pub fn pop_edit_checkpoint_for(&mut self, file_path_str: &str) -> Option<EditCheckpoint> {
225        let index =
226            self.edit_checkpoints.iter().rposition(|cp| cp.file_path_str == file_path_str)?;
227        self.edit_checkpoints.remove(index)
228    }
229
230    pub async fn init_pty_shell(&mut self) -> Result<()> {
231        let cwd = self.cwd.clone();
232        let restricted = self.bash_command_mode.bash_mode == BashMode::RestrictedMode;
233        // PtyShell::new forks+execs a shell and does a ~300ms blocking prompt init
234        // (thread::sleep + drain_output busy-wait). Run it on the blocking pool so
235        // it never pins a tokio worker thread.
236        let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted))
237            .await
238            .map_err(|e| {
239            crate::errors::WinxError::ShellInitializationError(format!("PTY init task failed: {e}"))
240        })??;
241        *self.pty_shell.lock().await = Some(shell);
242        Ok(())
243    }
244
245    pub fn update_cwd(&mut self, path: &Path) -> Result<()> {
246        self.cwd = path.to_path_buf();
247        Ok(())
248    }
249
250    pub fn update_workspace_root(&mut self, path: &Path) -> Result<()> {
251        self.workspace_root = path.to_path_buf();
252        Ok(())
253    }
254
255    pub fn is_command_allowed(&self, command: &str) -> bool {
256        if self.mode == Modes::Architect {
257            crate::utils::bash_parser::is_architect_command_allowed(command)
258        } else {
259            self.bash_command_mode.allowed_commands.is_allowed(command)
260        }
261    }
262
263    pub fn is_file_edit_allowed(&self, path: &str) -> bool {
264        self.file_edit_mode.allowed_globs.is_allowed(path)
265    }
266
267    pub fn is_file_write_allowed(&self, path: &str) -> bool {
268        self.write_if_empty_mode.allowed_globs.is_allowed(path)
269    }
270    pub fn save_state_to_disk(&self) -> Result<()> {
271        let snapshot = self.snapshot();
272        save_state_file(&self.current_thread_id, &snapshot)?;
273        Ok(())
274    }
275
276    pub fn snapshot(&self) -> BashStateSnapshot {
277        BashStateSnapshot::from_state(
278            &self.cwd.to_string_lossy(),
279            &self.workspace_root.to_string_lossy(),
280            &self.mode,
281            &self.bash_command_mode,
282            &self.file_edit_mode,
283            &self.write_if_empty_mode,
284            &self.whitelist_for_overwrite,
285            &self.current_thread_id,
286        )
287    }
288
289    pub fn apply_snapshot(&mut self, snapshot: &BashStateSnapshot) {
290        let (cwd, root, mode, bmode, emode, wmode, whitelist, tid) = snapshot.to_state_components();
291
292        self.cwd = PathBuf::from(cwd);
293        self.workspace_root = PathBuf::from(root);
294        self.mode = mode;
295        self.bash_command_mode = bmode;
296        self.file_edit_mode = emode;
297        self.write_if_empty_mode = wmode;
298        self.whitelist_for_overwrite = whitelist;
299        self.current_thread_id = tid;
300        self.initialized = true;
301    }
302
303    pub fn load_state_from_disk(&mut self, thread_id: &str) -> Result<bool> {
304        if let Some(snapshot) = load_state_file(thread_id)? {
305            self.apply_snapshot(&snapshot);
306            Ok(true)
307        } else {
308            Ok(false)
309        }
310    }
311
312    pub fn new_with_thread_id(thread_id: Option<&str>) -> Self {
313        let mut state = Self::new();
314
315        if let Some(tid) = thread_id {
316            if !tid.is_empty() {
317                if let Ok(true) = state.load_state_from_disk(tid) {
318                    info!("Loaded state for thread_id '{}'", tid);
319                } else {
320                    state.current_thread_id = tid.to_string();
321                }
322            }
323        }
324
325        state
326    }
327}
328
329pub fn generate_thread_id() -> String {
330    let mut rng = rand::rng();
331    format!("tid_{:x}", rng.random::<u64>())
332}
333
334#[cfg(test)]
335mod whitelist_range_tests {
336    use super::FileWhitelistData;
337
338    fn wl(ranges: &[(usize, usize)], total: usize) -> FileWhitelistData {
339        FileWhitelistData::new("h".to_string(), ranges.to_vec(), total)
340    }
341
342    #[test]
343    fn merge_collapses_overlap_and_adjacency() {
344        // (1,3)+(4,5) are adjacent (inclusive) -> (1,5); (7,9)+(8,12) overlap -> (7,12).
345        let w = wl(&[(4, 5), (1, 3), (8, 12), (7, 9)], 20);
346        assert_eq!(w.line_ranges_read, vec![(1, 5), (7, 12)]);
347    }
348
349    #[test]
350    fn re_reading_does_not_grow_unbounded() {
351        let mut w = wl(&[(1, 10)], 100);
352        for _ in 0..1000 {
353            w.merge_ranges(std::iter::once((1, 10)));
354            w.merge_ranges(std::iter::once((5, 15)));
355        }
356        // 1000 re-reads collapse to a single interval, not 2000 entries.
357        assert_eq!(w.line_ranges_read, vec![(1, 15)]);
358    }
359
360    #[test]
361    fn percentage_counts_distinct_lines_with_overlap() {
362        // lines 1..=5 and 3..=8 cover 1..=8 = 8 of 10 = 80%.
363        let w = wl(&[(1, 5), (3, 8)], 10);
364        assert!((w.get_percentage_read() - 80.0).abs() < 1e-9);
365        assert!(wl(&[(1, 10)], 10).is_read_enough());
366    }
367
368    #[test]
369    fn unread_ranges_are_the_gaps() {
370        // read 2..=4 and 7..=8 of 10 -> unread 1, 5..=6, 9..=10.
371        let w = wl(&[(2, 4), (7, 8)], 10);
372        assert_eq!(w.get_unread_ranges(), vec![(1, 1), (5, 6), (9, 10)]);
373    }
374
375    #[test]
376    fn out_of_range_entries_are_clamped() {
377        // A (0, 999) range on a 10-line file counts as full coverage, not a panic.
378        let w = wl(&[(0, 999)], 10);
379        assert!((w.get_percentage_read() - 100.0).abs() < 1e-9);
380        assert!(w.get_unread_ranges().is_empty());
381    }
382}