Skip to main content

ralph_workflow/checkpoint/
execution_history.rs

1//! Execution history tracking for checkpoint state.
2//!
3//! This module provides structures for tracking the execution history of a pipeline,
4//! enabling idempotent recovery and validation of state.
5
6use crate::checkpoint::timestamp;
7use crate::workspace::Workspace;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, VecDeque};
10use std::path::Path;
11use std::sync::Arc;
12
13fn deserialize_option_boxed_string_slice_none_if_empty<'de, D>(
14    deserializer: D,
15) -> Result<Option<Box<[String]>>, D::Error>
16where
17    D: serde::Deserializer<'de>,
18{
19    let opt = Option::<Vec<String>>::deserialize(deserializer)?;
20    Ok(match opt {
21        None => None,
22        Some(v) if v.is_empty() => None,
23        Some(v) => Some(v.into_boxed_slice()),
24    })
25}
26
27fn serialize_option_boxed_string_slice_empty_if_none_field<S, V>(
28    value: V,
29    serializer: S,
30) -> Result<S::Ok, S::Error>
31where
32    S: serde::Serializer,
33    V: std::ops::Deref<Target = Option<Box<[String]>>>,
34{
35    let values = (*value).as_deref();
36    serialize_option_boxed_string_slice_empty_if_none(values, serializer)
37}
38
39fn serialize_option_boxed_string_slice_empty_if_none<S>(
40    value: Option<&[String]>,
41    serializer: S,
42) -> Result<S::Ok, S::Error>
43where
44    S: serde::Serializer,
45{
46    use serde::ser::SerializeSeq;
47
48    if let Some(values) = value {
49        values.serialize(serializer)
50    } else {
51        let seq = serializer.serialize_seq(Some(0))?;
52        seq.end()
53    }
54}
55
56/// Outcome of an execution step.
57///
58/// # Memory Optimization
59///
60/// This enum uses Box<str> for string fields and Option<Box<[String]>> for
61/// collections to reduce allocation overhead when fields are empty or small.
62/// Vec<T> over-allocates capacity, while Box<[T]> uses exactly the needed space.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub enum StepOutcome {
65    /// Step completed successfully
66    Success {
67        output: Option<Box<str>>,
68        #[serde(
69            default,
70            deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty",
71            serialize_with = "serialize_option_boxed_string_slice_empty_if_none_field"
72        )]
73        files_modified: Option<Box<[String]>>,
74        #[serde(default)]
75        exit_code: Option<i32>,
76    },
77    /// Step failed with error
78    Failure {
79        error: Box<str>,
80        recoverable: bool,
81        #[serde(default)]
82        exit_code: Option<i32>,
83        #[serde(
84            default,
85            deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty",
86            serialize_with = "serialize_option_boxed_string_slice_empty_if_none_field"
87        )]
88        signals: Option<Box<[String]>>,
89    },
90    /// Step partially completed (may need retry)
91    Partial {
92        completed: Box<str>,
93        remaining: Box<str>,
94        #[serde(default)]
95        exit_code: Option<i32>,
96    },
97    /// Step was skipped (e.g., already done)
98    Skipped { reason: Box<str> },
99}
100
101impl StepOutcome {
102    /// Create a Success outcome with default values.
103    pub fn success(output: Option<String>, files_modified: Vec<String>) -> Self {
104        Self::Success {
105            output: output.map(String::into_boxed_str),
106            files_modified: if files_modified.is_empty() {
107                None
108            } else {
109                Some(files_modified.into_boxed_slice())
110            },
111            exit_code: Some(0),
112        }
113    }
114
115    /// Create a Failure outcome with default values.
116    #[must_use]
117    pub fn failure(error: String, recoverable: bool) -> Self {
118        Self::Failure {
119            error: error.into_boxed_str(),
120            recoverable,
121            exit_code: None,
122            signals: None,
123        }
124    }
125
126    /// Create a Partial outcome with default values.
127    #[must_use]
128    pub fn partial(completed: String, remaining: String) -> Self {
129        Self::Partial {
130            completed: completed.into_boxed_str(),
131            remaining: remaining.into_boxed_str(),
132            exit_code: None,
133        }
134    }
135
136    /// Create a Skipped outcome.
137    #[must_use]
138    pub fn skipped(reason: String) -> Self {
139        Self::Skipped {
140            reason: reason.into_boxed_str(),
141        }
142    }
143}
144
145/// Detailed information about files modified in a step.
146///
147/// # Memory Optimization
148///
149/// Uses `Option<Box<[String]>>` instead of `Vec<String>` to save memory:
150/// - Empty collections use `None` instead of empty Vec (saves 24 bytes per field)
151/// - Non-empty collections use `Box<[String]>` which is 16 bytes vs Vec's 24 bytes
152/// - Total savings: up to 72 bytes per instance when all fields are empty
153#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
154pub struct ModifiedFilesDetail {
155    #[serde(
156        default,
157        skip_serializing_if = "Option::is_none",
158        deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty"
159    )]
160    pub added: Option<Box<[String]>>,
161    #[serde(
162        default,
163        skip_serializing_if = "Option::is_none",
164        deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty"
165    )]
166    pub modified: Option<Box<[String]>>,
167    #[serde(
168        default,
169        skip_serializing_if = "Option::is_none",
170        deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty"
171    )]
172    pub deleted: Option<Box<[String]>>,
173}
174
175/// Summary of issues found and fixed during a step.
176#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
177pub struct IssuesSummary {
178    /// Number of issues found
179    #[serde(default)]
180    pub found: u32,
181    /// Number of issues fixed
182    #[serde(default)]
183    pub fixed: u32,
184    /// Description of issues (e.g., "3 clippy warnings, 2 test failures")
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub description: Option<String>,
187}
188
189/// A single execution step in the pipeline history.
190///
191/// # Memory Optimization
192///
193/// This struct uses Arc<str> for `phase` and `agent` fields to reduce memory
194/// usage through string interning. Phase names and agent names are repeated
195/// frequently across execution history entries, so sharing allocations via
196/// Arc<str> significantly reduces heap usage.
197///
198/// Serialization/deserialization is backward-compatible - Arc<str> is serialized
199/// as a regular string and can be deserialized from both old (String) and new
200/// (Arc<str>) checkpoint formats.
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202pub struct ExecutionStep {
203    /// Phase this step belongs to (interned via Arc<str>)
204    pub phase: Arc<str>,
205    /// Iteration number (for development/review iterations)
206    pub iteration: u32,
207    /// Type of step (e.g., "review", "fix", "commit")
208    pub step_type: Box<str>,
209    /// When this step was executed (ISO 8601 format string)
210    pub timestamp: String,
211    /// Outcome of the step
212    pub outcome: StepOutcome,
213    /// Agent that executed this step (interned via Arc<str>)
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub agent: Option<Arc<str>>,
216    /// Duration in seconds (if available)
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub duration_secs: Option<u64>,
219    /// When a checkpoint was saved during this step (ISO 8601 format string)
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub checkpoint_saved_at: Option<String>,
222    /// Git commit OID created during this step (if any)
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub git_commit_oid: Option<String>,
225    /// Detailed information about files modified
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub modified_files_detail: Option<ModifiedFilesDetail>,
228    /// The prompt text used for this step (for deterministic replay)
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub prompt_used: Option<String>,
231    /// Issues summary (found and fixed counts)
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub issues_summary: Option<IssuesSummary>,
234}
235
236impl ExecutionStep {
237    /// Create a new execution step.
238    ///
239    /// # Performance Note
240    ///
241    /// For optimal memory usage, use `new_with_pool` to intern repeated phase
242    /// and agent names via a `StringPool`. This constructor creates new Arc<str>
243    /// allocations for each call.
244    #[must_use]
245    pub fn new(phase: &str, iteration: u32, step_type: &str, outcome: StepOutcome) -> Self {
246        Self {
247            phase: Arc::from(phase),
248            iteration,
249            step_type: Box::from(step_type),
250            timestamp: timestamp(),
251            outcome,
252            agent: None,
253            duration_secs: None,
254            checkpoint_saved_at: None,
255            git_commit_oid: None,
256            modified_files_detail: None,
257            prompt_used: None,
258            issues_summary: None,
259        }
260    }
261
262    /// Create a new execution step using a `StringPool` for interning.
263    ///
264    /// This is the preferred constructor when creating many `ExecutionSteps`,
265    /// as it reduces memory usage by sharing allocations for repeated phase
266    /// and agent names.
267    pub fn new_with_pool(
268        phase: &str,
269        iteration: u32,
270        step_type: &str,
271        outcome: StepOutcome,
272        pool: &mut crate::checkpoint::StringPool,
273    ) -> Self {
274        Self {
275            phase: pool.intern_str(phase),
276            iteration,
277            step_type: Box::from(step_type),
278            timestamp: timestamp(),
279            outcome,
280            agent: None,
281            duration_secs: None,
282            checkpoint_saved_at: None,
283            git_commit_oid: None,
284            modified_files_detail: None,
285            prompt_used: None,
286            issues_summary: None,
287        }
288    }
289
290    /// Set the agent that executed this step.
291    #[must_use]
292    pub fn with_agent(mut self, agent: &str) -> Self {
293        self.agent = Some(Arc::from(agent));
294        self
295    }
296
297    /// Set the agent using a `StringPool` for interning.
298    #[must_use]
299    pub fn with_agent_pooled(
300        mut self,
301        agent: &str,
302        pool: &mut crate::checkpoint::StringPool,
303    ) -> Self {
304        self.agent = Some(pool.intern_str(agent));
305        self
306    }
307
308    /// Set the duration of this step.
309    #[must_use]
310    pub const fn with_duration(mut self, duration_secs: u64) -> Self {
311        self.duration_secs = Some(duration_secs);
312        self
313    }
314
315    /// Set the git commit OID created during this step.
316    #[must_use]
317    pub fn with_git_commit_oid(mut self, oid: &str) -> Self {
318        self.git_commit_oid = Some(oid.to_string());
319        self
320    }
321}
322
323include!("execution_history/file_snapshot.rs");
324
325/// Execution history tracking.
326#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
327pub struct ExecutionHistory {
328    /// All execution steps in order
329    pub steps: VecDeque<ExecutionStep>,
330    /// File snapshots for key files at checkpoint time
331    pub file_snapshots: HashMap<String, FileSnapshot>,
332}
333
334impl ExecutionHistory {
335    /// Execution history must be bounded.
336    ///
337    /// The historical unbounded `add_step` API is intentionally not available in
338    /// non-test builds to avoid reintroducing unbounded growth.
339    ///
340    /// ```compile_fail
341    /// use ralph_workflow::checkpoint::ExecutionHistory;
342    /// use ralph_workflow::checkpoint::execution_history::{ExecutionStep, StepOutcome};
343    ///
344    /// let mut history = ExecutionHistory::new();
345    /// let step = ExecutionStep::new("Development", 0, "dev_run", StepOutcome::success(None, vec![]));
346    ///
347    /// // Unbounded push is not part of the public API.
348    /// history.add_step(step);
349    /// ```
350    /// Create a new execution history.
351    #[must_use]
352    pub fn new() -> Self {
353        Self::default()
354    }
355
356    /// Add an execution step with explicit bounding (preferred method).
357    ///
358    /// This is the preferred method that enforces bounded memory growth.
359    /// Use this to prevent unbounded growth.
360    pub fn add_step_bounded(&mut self, step: ExecutionStep, limit: usize) {
361        self.steps.push_back(step);
362
363        // Enforce limit by dropping oldest entries.
364        // VecDeque::pop_front is O(1) amortized and avoids repeated memmoves.
365        while self.steps.len() > limit {
366            self.steps.pop_front();
367        }
368    }
369
370    /// Clone this execution history while enforcing a hard step limit.
371    ///
372    /// This is intended for resume paths where a legacy checkpoint may contain an
373    /// oversized `steps` buffer. Cloning only the tail avoids allocating memory
374    /// proportional to the checkpoint's full history.
375    #[must_use]
376    pub fn clone_bounded(&self, limit: usize) -> Self {
377        if limit == 0 {
378            return Self {
379                steps: VecDeque::new(),
380                file_snapshots: self.file_snapshots.clone(),
381            };
382        }
383
384        let len = self.steps.len();
385        if len <= limit {
386            return self.clone();
387        }
388
389        let keep_from = len - limit;
390        let mut steps = VecDeque::with_capacity(limit);
391        steps.extend(self.steps.iter().skip(keep_from).cloned());
392        Self {
393            steps,
394            file_snapshots: self.file_snapshots.clone(),
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests;