1use anyhow::{Context, Result};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15
16static WRITE_COUNTER: AtomicU64 = AtomicU64::new(1);
17
18const STATE_DIR_NAME: &str = "state";
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct LoopRunState {
26 pub loop_id: String,
28 pub step_index: u32,
30 pub last_artifact_path: Option<PathBuf>,
32 pub status: LoopStatus,
34 pub started_at: DateTime<Utc>,
36 pub updated_at: DateTime<Utc>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum LoopStatus {
44 Running,
46 Completed,
48 Failed,
50 Paused,
52}
53
54impl LoopRunState {
55 pub fn new(loop_id: impl Into<String>) -> Self {
57 let now = Utc::now();
58 Self {
59 loop_id: loop_id.into(),
60 step_index: 0,
61 last_artifact_path: None,
62 status: LoopStatus::Running,
63 started_at: now,
64 updated_at: now,
65 }
66 }
67
68 pub fn advance_step(&mut self) {
70 self.step_index = self.step_index.saturating_add(1);
71 self.updated_at = Utc::now();
72 }
73
74 pub fn mark_completed(&mut self) {
76 self.status = LoopStatus::Completed;
77 self.updated_at = Utc::now();
78 }
79
80 pub fn mark_failed(&mut self) {
82 self.status = LoopStatus::Failed;
83 self.updated_at = Utc::now();
84 }
85
86 pub fn mark_paused(&mut self) {
88 self.status = LoopStatus::Paused;
89 self.updated_at = Utc::now();
90 }
91
92 pub fn is_resumable(&self) -> bool {
94 matches!(self.status, LoopStatus::Paused | LoopStatus::Running)
95 }
96}
97
98pub fn state_dir(workspace_root: &Path) -> PathBuf {
102 workspace_root.join(".vtcode").join(STATE_DIR_NAME)
103}
104
105pub fn loop_state_path(workspace_root: &Path, loop_id: &str) -> PathBuf {
107 state_dir(workspace_root).join(format!("loop-{loop_id}.json"))
108}
109
110pub fn save_loop_state(workspace_root: &Path, state: &LoopRunState) -> Result<PathBuf> {
112 let dir = state_dir(workspace_root);
113 fs::create_dir_all(&dir).with_context(|| format!("Failed to create state directory {}", dir.display()))?;
114
115 let path = loop_state_path(workspace_root, &state.loop_id);
116 let serialized = serde_json::to_vec_pretty(state).context("Failed to serialize loop run state")?;
117
118 atomic_write(&path, &serialized)?;
119 Ok(path)
120}
121
122pub fn load_loop_state(workspace_root: &Path, loop_id: &str) -> Result<Option<LoopRunState>> {
124 let path = loop_state_path(workspace_root, loop_id);
125 if !path.exists() {
126 return Ok(None);
127 }
128 let raw = fs::read_to_string(&path).with_context(|| format!("Failed to read loop state {}", path.display()))?;
129 let state: LoopRunState =
130 serde_json::from_str(&raw).with_context(|| format!("Failed to parse {}", path.display()))?;
131 Ok(Some(state))
132}
133
134pub fn delete_loop_state(workspace_root: &Path, loop_id: &str) -> Result<bool> {
136 let path = loop_state_path(workspace_root, loop_id);
137 if path.exists() {
138 fs::remove_file(&path).with_context(|| format!("Failed to delete loop state {}", path.display()))?;
139 Ok(true)
140 } else {
141 Ok(false)
142 }
143}
144
145pub fn list_loop_states(workspace_root: &Path) -> Result<Vec<LoopRunState>> {
147 let dir = state_dir(workspace_root);
148 if !dir.exists() {
149 return Ok(Vec::new());
150 }
151 let mut states = Vec::new();
152 for entry in fs::read_dir(&dir).with_context(|| format!("Failed to read state directory {}", dir.display()))? {
153 let entry = entry?;
154 let path = entry.path();
155 if path
156 .file_name()
157 .and_then(|n| n.to_str())
158 .is_some_and(|n| n.starts_with("loop-") && n.ends_with(".json"))
159 {
160 let raw = fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
161 match serde_json::from_str::<LoopRunState>(&raw) {
162 Ok(state) => states.push(state),
163 Err(e) => {
164 tracing::warn!(path = %path.display(), error = %e, "Skipping malformed loop state file");
165 continue;
166 }
167 }
168 }
169 }
170 states.sort_by_key(|a| std::cmp::Reverse(a.updated_at));
171 Ok(states)
172}
173
174fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
177 if let Some(parent) = path.parent() {
178 fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
179 }
180
181 let temp_name = format!(
182 ".{}.tmp-{}",
183 path.file_name().and_then(|n| n.to_str()).unwrap_or("loop-state"),
184 WRITE_COUNTER.fetch_add(1, Ordering::Relaxed)
185 );
186 let temp_path = path.with_file_name(temp_name);
187 fs::write(&temp_path, content).with_context(|| format!("Failed to write {}", temp_path.display()))?;
188 fs::rename(&temp_path, path).with_context(|| format!("Failed to replace {}", path.display()))?;
189 Ok(())
190}
191
192#[cfg(test)]
195mod tests {
196 use super::*;
197 use tempfile::TempDir;
198
199 #[test]
200 fn loop_run_state_new_has_correct_defaults() {
201 let state = LoopRunState::new("test-loop");
202 assert_eq!(state.loop_id, "test-loop");
203 assert_eq!(state.step_index, 0);
204 assert_eq!(state.status, LoopStatus::Running);
205 assert!(state.last_artifact_path.is_none());
206 }
207
208 #[test]
209 fn loop_run_state_advance_step_increments() {
210 let mut state = LoopRunState::new("test");
211 assert_eq!(state.step_index, 0);
212 state.advance_step();
213 assert_eq!(state.step_index, 1);
214 state.advance_step();
215 assert_eq!(state.step_index, 2);
216 }
217
218 #[test]
219 fn loop_run_state_status_transitions() {
220 let mut state = LoopRunState::new("test");
221 assert_eq!(state.status, LoopStatus::Running);
222 assert!(state.is_resumable());
223
224 state.mark_paused();
225 assert_eq!(state.status, LoopStatus::Paused);
226 assert!(state.is_resumable());
227
228 state.mark_completed();
229 assert_eq!(state.status, LoopStatus::Completed);
230 assert!(!state.is_resumable());
231
232 let mut state2 = LoopRunState::new("test2");
233 state2.mark_failed();
234 assert_eq!(state2.status, LoopStatus::Failed);
235 assert!(!state2.is_resumable());
236 }
237
238 #[test]
239 fn loop_state_round_trip_persistence() {
240 let tmp = TempDir::new().expect("temp dir");
241 let mut state = LoopRunState::new("round-trip-test");
242 state.advance_step();
243 state.last_artifact_path = Some(PathBuf::from("/tmp/artifact.txt"));
244
245 let path = save_loop_state(tmp.path(), &state).expect("save");
246 assert!(path.exists());
247
248 let loaded = load_loop_state(tmp.path(), "round-trip-test")
249 .expect("load")
250 .expect("should exist");
251 assert_eq!(loaded.loop_id, "round-trip-test");
252 assert_eq!(loaded.step_index, 1);
253 assert!(loaded.last_artifact_path.is_some());
254 assert_eq!(loaded.status, LoopStatus::Running);
255 }
256
257 #[test]
258 fn load_loop_state_returns_none_for_missing() {
259 let tmp = TempDir::new().expect("temp dir");
260 let result = load_loop_state(tmp.path(), "nonexistent").expect("ok");
261 assert!(result.is_none());
262 }
263
264 #[test]
265 fn delete_loop_state_removes_file() {
266 let tmp = TempDir::new().expect("temp dir");
267 let state = LoopRunState::new("delete-me");
268 save_loop_state(tmp.path(), &state).expect("save");
269
270 let deleted = delete_loop_state(tmp.path(), "delete-me").expect("delete");
271 assert!(deleted);
272
273 let loaded = load_loop_state(tmp.path(), "delete-me").expect("load");
274 assert!(loaded.is_none());
275 }
276
277 #[test]
278 fn list_loop_states_returns_sorted_by_updated_at() {
279 let tmp = TempDir::new().expect("temp dir");
280
281 let mut state1 = LoopRunState::new("loop-1");
282 state1.updated_at = Utc::now() - chrono::Duration::hours(1);
283 save_loop_state(tmp.path(), &state1).expect("save");
284
285 let mut state2 = LoopRunState::new("loop-2");
286 state2.updated_at = Utc::now();
287 save_loop_state(tmp.path(), &state2).expect("save");
288
289 let states = list_loop_states(tmp.path()).expect("list");
290 assert_eq!(states.len(), 2);
291 assert_eq!(states[0].loop_id, "loop-2");
293 assert_eq!(states[1].loop_id, "loop-1");
294 }
295
296 #[test]
297 fn loop_state_serializes_status_variants() {
298 for status in [
299 LoopStatus::Running,
300 LoopStatus::Completed,
301 LoopStatus::Failed,
302 LoopStatus::Paused,
303 ] {
304 let state = LoopRunState {
305 loop_id: "serde-test".to_string(),
306 step_index: 0,
307 last_artifact_path: None,
308 status: status.clone(),
309 started_at: Utc::now(),
310 updated_at: Utc::now(),
311 };
312 let json = serde_json::to_string(&state).expect("serialize");
313 let deserialized: LoopRunState = serde_json::from_str(&json).expect("deserialize");
314 assert_eq!(deserialized.status, status);
315 }
316 }
317}