1use 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#[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
91fn 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
113fn 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 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 assert!(load_bash_state_from_path(&path).is_err());
378 assert!(!path.exists(), "corrupt file should be quarantined");
380 assert!(path.with_extension("json.corrupt").exists(), "quarantine file should exist");
381 Ok(())
382 }
383}