Skip to main content

winx_code_agent/state/
persistence.rs

1//! Bash state persistence module
2//!
3//! Provides disk persistence for `BashState`, compatible with WCGW Python implementation.
4//! State is stored in `~/.local/share/wcgw/bash_state/` as JSON files.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tracing::{error, warn};
12
13use crate::types::{
14    AllowedCommands, AllowedGlobs, BashCommandMode, BashMode, FileEditMode, Modes, WriteIfEmptyMode,
15};
16
17use super::bash_state::{EditMutationReceipt, FileWhitelistData};
18
19/// Snapshot of `BashState` that can be serialized to disk
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct BashStateSnapshot {
22    pub bash_command_mode: BashCommandModeSnapshot,
23    pub file_edit_mode: FileEditModeSnapshot,
24    pub write_if_empty_mode: WriteIfEmptyModeSnapshot,
25    pub whitelist_for_overwrite: HashMap<String, FileWhitelistDataSnapshot>,
26    pub mode: String,
27    pub workspace_root: String,
28    pub chat_id: String,
29    #[serde(default, skip_serializing_if = "String::is_empty")]
30    pub cwd: String,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub(crate) edit_mutation_receipts: Vec<EditMutationReceipt>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct BashCommandModeSnapshot {
37    pub bash_mode: String,
38    pub allowed_commands: AllowedCommandsSnapshot,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(untagged)]
43pub enum AllowedCommandsSnapshot {
44    All(String),
45    List(Vec<String>),
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct FileEditModeSnapshot {
50    pub allowed_globs: AllowedGlobsSnapshot,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct WriteIfEmptyModeSnapshot {
55    pub allowed_globs: AllowedGlobsSnapshot,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(untagged)]
60pub enum AllowedGlobsSnapshot {
61    All(String),
62    List(Vec<String>),
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct FileWhitelistDataSnapshot {
67    pub file_hash: String,
68    pub line_ranges_read: Vec<(usize, usize)>,
69    pub total_lines: usize,
70}
71
72pub fn get_state_dir() -> Result<PathBuf> {
73    let home = home::home_dir().unwrap_or_else(|| {
74        warn!(
75            "$HOME is not set; persisting bash session state under /tmp \
76             (ephemeral across reboots, and shared if multiple users hit the same thread_id)"
77        );
78        PathBuf::from("/tmp")
79    });
80    let bash_state_dir = home.join(".local/share/wcgw/bash_state");
81    if !bash_state_dir.exists() {
82        fs::create_dir_all(&bash_state_dir)?;
83    }
84    Ok(bash_state_dir)
85}
86
87fn get_state_file_path(thread_id: &str) -> Result<PathBuf> {
88    Ok(get_state_dir()?.join(format!("{thread_id}_bash_state.json")))
89}
90
91/// Write `content` to `path` atomically: a uniquely-named temp file in the same
92/// directory, renamed over the target. A reader (or a concurrent writer for the
93/// same `thread_id`) sees either the complete old file or the complete new one —
94/// never a half-written, un-parseable JSON.
95///
96/// No `fsync`: `persist_state` runs several times per tool call, and a per-write
97/// fsync would add real latency there. The atomic rename already prevents torn
98/// writes; we trade only durability-against-power-loss, which is fine for
99/// resumable session state.
100fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
101    use std::io::Write;
102    let parent =
103        path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
104    let mut tmp = tempfile::Builder::new()
105        .prefix(".winx-state-")
106        .tempfile_in(parent)
107        .with_context(|| format!("create temp state file in {}", parent.display()))?;
108    tmp.write_all(content)?;
109    tmp.persist(path).map_err(|e| e.error)?;
110    Ok(())
111}
112
113/// Parse a state snapshot, quarantining the file on corruption.
114///
115/// A corrupt state file must not silently re-fail every boot. We move it aside to
116/// `<name>.corrupt`, log loudly (the saved session — including its security mode —
117/// is gone), and return the parse error. Callers currently treat that as "no
118/// saved state"; the loud log makes the loss visible instead of silent.
119fn parse_state_or_quarantine(path: &Path, json: &str) -> Result<BashStateSnapshot> {
120    match serde_json::from_str(json) {
121        Ok(snapshot) => Ok(snapshot),
122        Err(parse_err) => {
123            let quarantine = path.with_extension("json.corrupt");
124            match fs::rename(path, &quarantine) {
125                Ok(()) => error!(
126                    path = %path.display(),
127                    quarantine = %quarantine.display(),
128                    "corrupt bash state quarantined; saved session (incl. its mode) lost: {parse_err}"
129                ),
130                Err(rename_err) => warn!(
131                    path = %path.display(),
132                    "failed to quarantine corrupt bash state: {rename_err}"
133                ),
134            }
135            Err(parse_err).context("parsing persisted bash state")
136        }
137    }
138}
139
140pub fn save_bash_state(thread_id: &str, state: &BashStateSnapshot) -> Result<()> {
141    if thread_id.is_empty() {
142        return Ok(());
143    }
144    let json = serde_json::to_string_pretty(state)?;
145    atomic_write(&get_state_file_path(thread_id)?, json.as_bytes())?;
146    Ok(())
147}
148
149pub fn save_bash_state_to_path(path: &Path, state: &BashStateSnapshot) -> Result<()> {
150    let json = serde_json::to_string_pretty(state)?;
151    atomic_write(path, json.as_bytes())?;
152    Ok(())
153}
154
155pub fn load_bash_state(thread_id: &str) -> Result<Option<BashStateSnapshot>> {
156    if thread_id.is_empty() {
157        return Ok(None);
158    }
159    let path = get_state_file_path(thread_id)?;
160    if !path.exists() {
161        return Ok(None);
162    }
163    let json = fs::read_to_string(&path)?;
164    Ok(Some(parse_state_or_quarantine(&path, &json)?))
165}
166
167pub fn load_bash_state_from_path(path: &Path) -> Result<Option<BashStateSnapshot>> {
168    if !path.exists() {
169        return Ok(None);
170    }
171    let json = fs::read_to_string(path)?;
172    Ok(Some(parse_state_or_quarantine(path, &json)?))
173}
174
175pub fn delete_bash_state(thread_id: &str) -> Result<()> {
176    if thread_id.is_empty() {
177        return Ok(());
178    }
179    let path = get_state_file_path(thread_id)?;
180    if path.exists() {
181        fs::remove_file(path)?;
182    }
183    Ok(())
184}
185
186impl BashStateSnapshot {
187    #[allow(clippy::too_many_arguments)]
188    pub(crate) fn from_state(
189        cwd: &str,
190        workspace_root: &str,
191        mode: Modes,
192        bash_command_mode: &BashCommandMode,
193        file_edit_mode: &FileEditMode,
194        write_if_empty_mode: &WriteIfEmptyMode,
195        whitelist: &HashMap<String, FileWhitelistData>,
196        thread_id: &str,
197        edit_mutation_receipts: &std::collections::VecDeque<EditMutationReceipt>,
198    ) -> Self {
199        Self {
200            bash_command_mode: BashCommandModeSnapshot::from(bash_command_mode),
201            file_edit_mode: FileEditModeSnapshot::from(file_edit_mode),
202            write_if_empty_mode: WriteIfEmptyModeSnapshot::from(write_if_empty_mode),
203            whitelist_for_overwrite: whitelist
204                .iter()
205                .map(|(k, v)| (k.clone(), FileWhitelistDataSnapshot::from(v)))
206                .collect(),
207            mode: mode.to_string(),
208            workspace_root: workspace_root.to_string(),
209            chat_id: thread_id.to_string(),
210            cwd: if cwd == workspace_root { String::new() } else { cwd.to_string() },
211            edit_mutation_receipts: edit_mutation_receipts.iter().cloned().collect(),
212        }
213    }
214
215    pub fn to_state_components(
216        &self,
217    ) -> (
218        String,
219        String,
220        Modes,
221        BashCommandMode,
222        FileEditMode,
223        WriteIfEmptyMode,
224        HashMap<String, FileWhitelistData>,
225        String,
226    ) {
227        let mode = match self.mode.as_str() {
228            "architect" => Modes::Architect,
229            "code_writer" => Modes::CodeWriter,
230            _ => Modes::Wcgw,
231        };
232        let whitelist = self
233            .whitelist_for_overwrite
234            .iter()
235            .map(|(k, v)| (k.clone(), v.to_whitelist_data()))
236            .collect();
237        let cwd = if self.cwd.is_empty() { self.workspace_root.clone() } else { self.cwd.clone() };
238        (
239            cwd,
240            self.workspace_root.clone(),
241            mode,
242            self.bash_command_mode.to_bash_command_mode(),
243            self.file_edit_mode.to_file_edit_mode(),
244            self.write_if_empty_mode.to_write_if_empty_mode(),
245            whitelist,
246            self.chat_id.clone(),
247        )
248    }
249}
250
251impl From<&BashCommandMode> for BashCommandModeSnapshot {
252    fn from(mode: &BashCommandMode) -> Self {
253        Self {
254            bash_mode: match mode.bash_mode {
255                BashMode::RestrictedMode => "restricted_mode".to_string(),
256                BashMode::NormalMode => "normal_mode".to_string(),
257            },
258            allowed_commands: match &mode.allowed_commands {
259                AllowedCommands::All(s) => AllowedCommandsSnapshot::All(s.clone()),
260                AllowedCommands::List(l) => AllowedCommandsSnapshot::List(l.clone()),
261            },
262        }
263    }
264}
265
266impl BashCommandModeSnapshot {
267    fn to_bash_command_mode(&self) -> BashCommandMode {
268        BashCommandMode {
269            bash_mode: if self.bash_mode == "restricted_mode" {
270                BashMode::RestrictedMode
271            } else {
272                BashMode::NormalMode
273            },
274            allowed_commands: match &self.allowed_commands {
275                AllowedCommandsSnapshot::All(s) => AllowedCommands::All(s.clone()),
276                AllowedCommandsSnapshot::List(l) => AllowedCommands::List(l.clone()),
277            },
278        }
279    }
280}
281
282impl From<&FileEditMode> for FileEditModeSnapshot {
283    fn from(mode: &FileEditMode) -> Self {
284        Self {
285            allowed_globs: match &mode.allowed_globs {
286                AllowedGlobs::All(s) => AllowedGlobsSnapshot::All(s.clone()),
287                AllowedGlobs::List(l) => AllowedGlobsSnapshot::List(l.clone()),
288            },
289        }
290    }
291}
292
293impl FileEditModeSnapshot {
294    fn to_file_edit_mode(&self) -> FileEditMode {
295        FileEditMode {
296            allowed_globs: match &self.allowed_globs {
297                AllowedGlobsSnapshot::All(s) => AllowedGlobs::All(s.clone()),
298                AllowedGlobsSnapshot::List(l) => AllowedGlobs::List(l.clone()),
299            },
300        }
301    }
302}
303
304impl From<&WriteIfEmptyMode> for WriteIfEmptyModeSnapshot {
305    fn from(mode: &WriteIfEmptyMode) -> Self {
306        Self {
307            allowed_globs: match &mode.allowed_globs {
308                AllowedGlobs::All(s) => AllowedGlobsSnapshot::All(s.clone()),
309                AllowedGlobs::List(l) => AllowedGlobsSnapshot::List(l.clone()),
310            },
311        }
312    }
313}
314
315impl WriteIfEmptyModeSnapshot {
316    fn to_write_if_empty_mode(&self) -> WriteIfEmptyMode {
317        WriteIfEmptyMode {
318            allowed_globs: match &self.allowed_globs {
319                AllowedGlobsSnapshot::All(s) => AllowedGlobs::All(s.clone()),
320                AllowedGlobsSnapshot::List(l) => AllowedGlobs::List(l.clone()),
321            },
322        }
323    }
324}
325
326impl From<&FileWhitelistData> for FileWhitelistDataSnapshot {
327    fn from(d: &FileWhitelistData) -> Self {
328        Self {
329            file_hash: d.file_hash.clone(),
330            line_ranges_read: d.line_ranges_read.clone(),
331            total_lines: d.total_lines,
332        }
333    }
334}
335
336impl FileWhitelistDataSnapshot {
337    fn to_whitelist_data(&self) -> FileWhitelistData {
338        FileWhitelistData {
339            file_hash: self.file_hash.clone(),
340            line_ranges_read: self.line_ranges_read.clone(),
341            total_lines: self.total_lines,
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::{atomic_write, load_bash_state_from_path};
349    use anyhow::Result;
350
351    #[test]
352    fn atomic_write_round_trips_and_replaces() -> Result<()> {
353        let dir = tempfile::tempdir()?;
354        let path = dir.path().join("data.bin");
355        atomic_write(&path, b"hello atomic")?;
356        assert_eq!(std::fs::read(&path)?, b"hello atomic");
357        // Overwriting is also atomic and leaves a single clean file.
358        atomic_write(&path, b"second")?;
359        assert_eq!(std::fs::read(&path)?, b"second");
360        Ok(())
361    }
362
363    #[test]
364    fn missing_state_file_is_ok_none() -> Result<()> {
365        let dir = tempfile::tempdir()?;
366        let path = dir.path().join("absent_bash_state.json");
367        assert!(load_bash_state_from_path(&path)?.is_none());
368        Ok(())
369    }
370
371    #[test]
372    fn corrupt_state_is_quarantined_and_surfaces_error() -> Result<()> {
373        let dir = tempfile::tempdir()?;
374        let path = dir.path().join("t1_bash_state.json");
375        std::fs::write(&path, "{ not valid json ")?;
376        // Corruption must NOT degrade into Ok(None) (a silent fresh session).
377        assert!(load_bash_state_from_path(&path).is_err());
378        // The bad file is moved aside so it doesn't re-fail every boot.
379        assert!(!path.exists(), "corrupt file should be quarantined");
380        assert!(path.with_extension("json.corrupt").exists(), "quarantine file should exist");
381        Ok(())
382    }
383}