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)]
171#[path = "state_tests.rs"]
172mod state_tests;