Skip to main content

ito_core/ralph/
state.rs

1//! Persistent Ralph loop state.
2//!
3//! The Ralph loop stores a small amount of JSON state on disk so users can:
4//! - inspect iteration history (duration, whether completion was detected)
5//! - add or clear additional context that is appended to future prompts
6//!
7//! State is stored under `.ito/.state/ralph/<change-id>/`.
8
9use crate::errors::{CoreError, CoreResult};
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15/// One historical record for a Ralph iteration.
16pub struct RalphHistoryEntry {
17    /// Wall clock time (ms since epoch) when the iteration finished.
18    pub timestamp: i64,
19    /// Duration (ms) the harness run took.
20    pub duration: i64,
21    /// Whether the completion promise token was observed in harness stdout.
22    pub completion_promise_found: bool,
23    /// Number of changed files in the git working tree after the iteration.
24    pub file_changes_count: u32,
25    /// Harness exit code for the iteration.
26    #[serde(default)]
27    pub harness_exit_code: i32,
28    /// Whether Ralph accepted the completion promise after validation.
29    #[serde(default)]
30    pub completion_validated: bool,
31    /// Effective working directory used for the iteration.
32    #[serde(default)]
33    pub effective_cwd: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37#[serde(rename_all = "camelCase")]
38/// Saved state for a Ralph loop scoped to a specific change.
39pub struct RalphState {
40    /// Change id this state belongs to.
41    pub change_id: String,
42    /// Last completed iteration number.
43    pub iteration: u32,
44    /// History entries for completed iterations.
45    pub history: Vec<RalphHistoryEntry>,
46    /// Display path for the context file (used by some UIs).
47    pub context_file: String,
48    /// Summary of the most recent iteration outcome.
49    #[serde(default)]
50    pub last_outcome: Option<String>,
51    /// Most recent harness or validation failure details, when present.
52    #[serde(default)]
53    pub last_failure: Option<String>,
54}
55
56/// Return the on-disk directory for Ralph state for `change_id`.
57pub fn ralph_state_dir(ito_path: &Path, change_id: &str) -> PathBuf {
58    if !is_safe_change_id_segment(change_id) {
59        return ito_path
60            .join(".state")
61            .join("ralph")
62            .join("invalid-change-id");
63    }
64    ito_path.join(".state").join("ralph").join(change_id)
65}
66
67/// Return the path to `state.json` for `change_id`.
68pub fn ralph_state_json_path(ito_path: &Path, change_id: &str) -> PathBuf {
69    ralph_state_dir(ito_path, change_id).join("state.json")
70}
71
72/// Return the path to `context.md` for `change_id`.
73pub fn ralph_context_path(ito_path: &Path, change_id: &str) -> PathBuf {
74    ralph_state_dir(ito_path, change_id).join("context.md")
75}
76
77/// Load saved state for `change_id`.
78pub fn load_state(ito_path: &Path, change_id: &str) -> CoreResult<Option<RalphState>> {
79    let p = ralph_state_json_path(ito_path, change_id);
80    if !p.exists() {
81        return Ok(None);
82    }
83    let raw = ito_common::io::read_to_string_std(&p)
84        .map_err(|e| CoreError::io(format!("reading {}", p.display()), e))?;
85    let state = serde_json::from_str(&raw)
86        .map_err(|e| CoreError::Parse(format!("JSON error parsing {p}: {e}", p = p.display())))?;
87    Ok(Some(state))
88}
89
90/// Persist `state` for `change_id`.
91pub fn save_state(ito_path: &Path, change_id: &str, state: &RalphState) -> CoreResult<()> {
92    let dir = ralph_state_dir(ito_path, change_id);
93    ito_common::io::create_dir_all_std(&dir)
94        .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
95    let p = ralph_state_json_path(ito_path, change_id);
96    let raw = serde_json::to_string_pretty(state)
97        .map_err(|e| CoreError::Parse(format!("JSON error serializing state: {e}")))?;
98    ito_common::io::write_std(&p, raw)
99        .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
100    Ok(())
101}
102
103/// Load the saved context markdown for `change_id`.
104///
105/// Missing files return an empty string.
106pub fn load_context(ito_path: &Path, change_id: &str) -> CoreResult<String> {
107    let p = ralph_context_path(ito_path, change_id);
108    if !p.exists() {
109        return Ok(String::new());
110    }
111    ito_common::io::read_to_string_std(&p)
112        .map_err(|e| CoreError::io(format!("reading {}", p.display()), e))
113}
114
115/// Append `text` to the saved context for `change_id`.
116///
117/// Empty/whitespace-only input is ignored.
118pub fn append_context(ito_path: &Path, change_id: &str, text: &str) -> CoreResult<()> {
119    let dir = ralph_state_dir(ito_path, change_id);
120    ito_common::io::create_dir_all_std(&dir)
121        .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
122    let p = ralph_context_path(ito_path, change_id);
123    let existing_result = ito_common::io::read_to_string_std(&p);
124    let mut existing = match existing_result {
125        Ok(s) => s,
126        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
127        Err(e) => return Err(CoreError::io(format!("reading {}", p.display()), e)),
128    };
129
130    let trimmed = text.trim();
131    if trimmed.is_empty() {
132        return Ok(());
133    }
134
135    if !existing.trim().is_empty() {
136        existing.push_str("\n\n");
137    }
138    existing.push_str(trimmed);
139    existing.push('\n');
140    ito_common::io::write_std(&p, existing)
141        .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
142    Ok(())
143}
144
145/// Clear the saved context for `change_id`.
146pub fn clear_context(ito_path: &Path, change_id: &str) -> CoreResult<()> {
147    let dir = ralph_state_dir(ito_path, change_id);
148    ito_common::io::create_dir_all_std(&dir)
149        .map_err(|e| CoreError::io(format!("creating directory {}", dir.display()), e))?;
150    let p = ralph_context_path(ito_path, change_id);
151    ito_common::io::write_std(&p, "")
152        .map_err(|e| CoreError::io(format!("writing {}", p.display()), e))?;
153    Ok(())
154}
155
156fn is_safe_change_id_segment(change_id: &str) -> bool {
157    let change_id = change_id.trim();
158    if change_id.is_empty() {
159        return false;
160    }
161    if change_id.len() > 256 {
162        return false;
163    }
164    if change_id.contains('/') || change_id.contains('\\') || change_id.contains("..") {
165        return false;
166    }
167    true
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn ralph_state_dir_uses_safe_fallback_for_invalid_change_ids() {
176        let ito = std::path::Path::new("/tmp/repo/.ito");
177        let path = ralph_state_dir(ito, "../escape");
178        assert!(path.ends_with(".state/ralph/invalid-change-id"));
179    }
180
181    #[test]
182    fn save_and_load_state_round_trip() {
183        let td = tempfile::tempdir().unwrap();
184        let ito = td.path().join(".ito");
185        let change_id = "001-01_test";
186        let state = RalphState {
187            change_id: change_id.to_string(),
188            iteration: 5,
189            history: vec![RalphHistoryEntry {
190                timestamp: 1234567890,
191                duration: 5000,
192                completion_promise_found: true,
193                file_changes_count: 3,
194                harness_exit_code: 0,
195                completion_validated: true,
196                effective_cwd: "/tmp/worktree".to_string(),
197            }],
198            context_file: ".ito/.state/ralph/001-01_test/context.md".to_string(),
199            last_outcome: Some("validated-complete".to_string()),
200            last_failure: None,
201        };
202        save_state(&ito, change_id, &state).unwrap();
203        let loaded = load_state(&ito, change_id).unwrap();
204        assert!(loaded.is_some());
205        let loaded = loaded.unwrap();
206        assert_eq!(loaded.change_id, state.change_id);
207        assert_eq!(loaded.iteration, state.iteration);
208        assert_eq!(loaded.history.len(), state.history.len());
209        assert_eq!(loaded.history[0].timestamp, state.history[0].timestamp);
210        assert_eq!(loaded.history[0].duration, state.history[0].duration);
211        assert_eq!(
212            loaded.history[0].completion_promise_found,
213            state.history[0].completion_promise_found
214        );
215        assert_eq!(
216            loaded.history[0].file_changes_count,
217            state.history[0].file_changes_count
218        );
219        assert_eq!(loaded.history[0].harness_exit_code, 0);
220        assert!(loaded.history[0].completion_validated);
221        assert_eq!(loaded.history[0].effective_cwd, "/tmp/worktree");
222        assert_eq!(loaded.context_file, state.context_file);
223        assert_eq!(loaded.last_outcome.as_deref(), Some("validated-complete"));
224        assert_eq!(loaded.last_failure, None);
225    }
226
227    #[test]
228    fn load_state_returns_none_when_missing() {
229        let td = tempfile::tempdir().unwrap();
230        let ito = td.path().join(".ito");
231        let result = load_state(&ito, "nonexistent").unwrap();
232        assert!(result.is_none());
233    }
234
235    #[test]
236    fn load_state_backfills_missing_new_fields() {
237        let td = tempfile::tempdir().unwrap();
238        let ito = td.path().join(".ito");
239        let change_id = "001-01_test";
240        let dir = ralph_state_dir(&ito, change_id);
241        std::fs::create_dir_all(&dir).unwrap();
242        let raw = r#"{
243  "changeId": "001-01_test",
244  "iteration": 2,
245  "history": [
246    {
247      "timestamp": 1,
248      "duration": 2,
249      "completionPromiseFound": true,
250      "fileChangesCount": 3
251    }
252  ],
253  "contextFile": ".ito/.state/ralph/001-01_test/context.md"
254}"#;
255        std::fs::write(ralph_state_json_path(&ito, change_id), raw).unwrap();
256
257        let loaded = load_state(&ito, change_id).unwrap().unwrap();
258        assert_eq!(loaded.history[0].harness_exit_code, 0);
259        assert!(!loaded.history[0].completion_validated);
260        assert_eq!(loaded.history[0].effective_cwd, "");
261        assert_eq!(loaded.last_outcome, None);
262        assert_eq!(loaded.last_failure, None);
263    }
264
265    #[test]
266    fn is_safe_change_id_segment_rejects_empty() {
267        let ito = tempfile::tempdir().unwrap();
268        let ito_path = ito.path().join(".ito");
269        let path = ralph_state_dir(&ito_path, "");
270        assert!(path.ends_with(".state/ralph/invalid-change-id"));
271    }
272
273    #[test]
274    fn is_safe_change_id_segment_rejects_too_long() {
275        let ito = tempfile::tempdir().unwrap();
276        let ito_path = ito.path().join(".ito");
277        let long_id = "a".repeat(257);
278        let path = ralph_state_dir(&ito_path, &long_id);
279        assert!(path.ends_with(".state/ralph/invalid-change-id"));
280    }
281
282    #[test]
283    fn is_safe_change_id_segment_rejects_backslash() {
284        let ito = tempfile::tempdir().unwrap();
285        let ito_path = ito.path().join(".ito");
286        let path = ralph_state_dir(&ito_path, "foo\\bar");
287        assert!(path.ends_with(".state/ralph/invalid-change-id"));
288    }
289
290    #[test]
291    fn is_safe_change_id_segment_accepts_valid() {
292        let ito = tempfile::tempdir().unwrap();
293        let ito_path = ito.path().join(".ito");
294        let path = ralph_state_dir(&ito_path, "003-05_my-change");
295        assert!(path.ends_with("003-05_my-change"));
296    }
297
298    #[test]
299    fn append_context_no_op_on_whitespace() {
300        let td = tempfile::tempdir().unwrap();
301        let ito = td.path().join(".ito");
302        let change_id = "001-01_test";
303        append_context(&ito, change_id, "   \n  ").unwrap();
304        let context_path = ralph_context_path(&ito, change_id);
305        if context_path.exists() {
306            let content = ito_common::io::read_to_string_std(&context_path).unwrap();
307            assert!(content.is_empty());
308        }
309    }
310
311    #[test]
312    fn load_context_returns_empty_when_missing() {
313        let td = tempfile::tempdir().unwrap();
314        let ito = td.path().join(".ito");
315        let result = load_context(&ito, "nonexistent").unwrap();
316        assert_eq!(result, "");
317    }
318
319    #[test]
320    fn ralph_state_json_path_correct() {
321        let ito = std::path::Path::new("/tmp/repo/.ito");
322        let path = ralph_state_json_path(ito, "001-01_test");
323        assert!(path.ends_with("state.json"));
324    }
325
326    #[test]
327    fn ralph_context_path_correct() {
328        let ito = std::path::Path::new("/tmp/repo/.ito");
329        let path = ralph_context_path(ito, "001-01_test");
330        assert!(path.ends_with("context.md"));
331    }
332}