ralph_workflow/checkpoint/
execution_history.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub enum StepOutcome {
65 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 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 Partial {
92 completed: Box<str>,
93 remaining: Box<str>,
94 #[serde(default)]
95 exit_code: Option<i32>,
96 },
97 Skipped { reason: Box<str> },
99}
100
101impl StepOutcome {
102 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 #[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 #[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 #[must_use]
138 pub fn skipped(reason: String) -> Self {
139 Self::Skipped {
140 reason: reason.into_boxed_str(),
141 }
142 }
143}
144
145#[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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
177pub struct IssuesSummary {
178 #[serde(default)]
180 pub found: u32,
181 #[serde(default)]
183 pub fixed: u32,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub description: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202pub struct ExecutionStep {
203 pub phase: Arc<str>,
205 pub iteration: u32,
207 pub step_type: Box<str>,
209 pub timestamp: String,
211 pub outcome: StepOutcome,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub agent: Option<Arc<str>>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub duration_secs: Option<u64>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub checkpoint_saved_at: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub git_commit_oid: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub modified_files_detail: Option<ModifiedFilesDetail>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub prompt_used: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub issues_summary: Option<IssuesSummary>,
234}
235
236impl ExecutionStep {
237 #[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 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 #[must_use]
292 pub fn with_agent(mut self, agent: &str) -> Self {
293 self.agent = Some(Arc::from(agent));
294 self
295 }
296
297 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
327pub struct ExecutionHistory {
328 pub steps: VecDeque<ExecutionStep>,
330 pub file_snapshots: HashMap<String, FileSnapshot>,
332}
333
334impl ExecutionHistory {
335 #[must_use]
352 pub fn new() -> Self {
353 Self::default()
354 }
355
356 pub fn add_step_bounded(&mut self, step: ExecutionStep, limit: usize) {
361 self.steps.push_back(step);
362
363 while self.steps.len() > limit {
366 self.steps.pop_front();
367 }
368 }
369
370 #[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;