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