ralph-workflow 0.7.18

PROMPT-driven multi-agent orchestrator for git repos
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! Execution history tracking for checkpoint state.
//!
//! This module provides structures for tracking the execution history of a pipeline,
//! enabling idempotent recovery and validation of state.

pub mod compression;

use crate::checkpoint::timestamp;
use crate::workspace::Workspace;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::path::Path;
use std::sync::Arc;

fn deserialize_option_boxed_string_slice_none_if_empty<'de, D>(
    deserializer: D,
) -> Result<Option<Box<[String]>>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let opt = Option::<Vec<String>>::deserialize(deserializer)?;
    Ok(match opt {
        None => None,
        Some(v) if v.is_empty() => None,
        Some(v) => Some(v.into_boxed_slice()),
    })
}

fn serialize_option_boxed_string_slice_empty_if_none_field<S, V>(
    value: V,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
    V: std::ops::Deref<Target = Option<Box<[String]>>>,
{
    let values = (*value).as_deref();
    serialize_option_boxed_string_slice_empty_if_none(values, serializer)
}

fn serialize_option_boxed_string_slice_empty_if_none<S>(
    value: Option<&[String]>,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    use serde::ser::SerializeSeq;

    if let Some(values) = value {
        values.serialize(serializer)
    } else {
        let seq = serializer.serialize_seq(Some(0))?;
        seq.end()
    }
}

/// Outcome of an execution step.
///
/// # Memory Optimization
///
/// This enum uses Box<str> for string fields and Option<Box<[String]>> for
/// collections to reduce allocation overhead when fields are empty or small.
/// Vec<T> over-allocates capacity, while Box<[T]> uses exactly the needed space.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum StepOutcome {
    /// Step completed successfully
    Success {
        output: Option<Box<str>>,
        #[serde(
            default,
            deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty",
            serialize_with = "serialize_option_boxed_string_slice_empty_if_none_field"
        )]
        files_modified: Option<Box<[String]>>,
        #[serde(default)]
        exit_code: Option<i32>,
    },
    /// Step failed with error
    Failure {
        error: Box<str>,
        recoverable: bool,
        #[serde(default)]
        exit_code: Option<i32>,
        #[serde(
            default,
            deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty",
            serialize_with = "serialize_option_boxed_string_slice_empty_if_none_field"
        )]
        signals: Option<Box<[String]>>,
    },
    /// Step partially completed (may need retry)
    Partial {
        completed: Box<str>,
        remaining: Box<str>,
        #[serde(default)]
        exit_code: Option<i32>,
    },
    /// Step was skipped (e.g., already done)
    Skipped { reason: Box<str> },
}

impl StepOutcome {
    /// Create a Success outcome with default values.
    pub fn success(output: Option<String>, files_modified: Vec<String>) -> Self {
        Self::Success {
            output: output.map(String::into_boxed_str),
            files_modified: if files_modified.is_empty() {
                None
            } else {
                Some(files_modified.into_boxed_slice())
            },
            exit_code: Some(0),
        }
    }

    /// Create a Failure outcome with default values.
    #[must_use]
    pub fn failure(error: String, recoverable: bool) -> Self {
        Self::Failure {
            error: error.into_boxed_str(),
            recoverable,
            exit_code: None,
            signals: None,
        }
    }

    /// Create a Partial outcome with default values.
    #[must_use]
    pub fn partial(completed: String, remaining: String) -> Self {
        Self::Partial {
            completed: completed.into_boxed_str(),
            remaining: remaining.into_boxed_str(),
            exit_code: None,
        }
    }

    /// Create a Skipped outcome.
    #[must_use]
    pub fn skipped(reason: String) -> Self {
        Self::Skipped {
            reason: reason.into_boxed_str(),
        }
    }
}

/// Detailed information about files modified in a step.
///
/// # Memory Optimization
///
/// Uses `Option<Box<[String]>>` instead of `Vec<String>` to save memory:
/// - Empty collections use `None` instead of empty Vec (saves 24 bytes per field)
/// - Non-empty collections use `Box<[String]>` which is 16 bytes vs Vec's 24 bytes
/// - Total savings: up to 72 bytes per instance when all fields are empty
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ModifiedFilesDetail {
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty"
    )]
    pub added: Option<Box<[String]>>,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty"
    )]
    pub modified: Option<Box<[String]>>,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "deserialize_option_boxed_string_slice_none_if_empty"
    )]
    pub deleted: Option<Box<[String]>>,
}

/// Summary of issues found and fixed during a step.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct IssuesSummary {
    /// Number of issues found
    #[serde(default)]
    pub found: u32,
    /// Number of issues fixed
    #[serde(default)]
    pub fixed: u32,
    /// Description of issues (e.g., "3 clippy warnings, 2 test failures")
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// A single execution step in the pipeline history.
///
/// # Memory Optimization
///
/// This struct uses Arc<str> for `phase` and `agent` fields to reduce memory
/// usage through string interning. Phase names and agent names are repeated
/// frequently across execution history entries, so sharing allocations via
/// Arc<str> significantly reduces heap usage.
///
/// Serialization/deserialization is backward-compatible - Arc<str> is serialized
/// as a regular string and can be deserialized from both old (String) and new
/// (Arc<str>) checkpoint formats.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecutionStep {
    /// Phase this step belongs to (interned via Arc<str>)
    pub phase: Arc<str>,
    /// Iteration number (for development/review iterations)
    pub iteration: u32,
    /// Type of step (e.g., "review", "fix", "commit")
    pub step_type: Box<str>,
    /// When this step was executed (ISO 8601 format string)
    pub timestamp: String,
    /// Outcome of the step
    pub outcome: StepOutcome,
    /// Agent that executed this step (interned via Arc<str>)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent: Option<Arc<str>>,
    /// Duration in seconds (if available)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_secs: Option<u64>,
    /// When a checkpoint was saved during this step (ISO 8601 format string)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub checkpoint_saved_at: Option<String>,
    /// Git commit OID created during this step (if any)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git_commit_oid: Option<String>,
    /// Detailed information about files modified
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub modified_files_detail: Option<ModifiedFilesDetail>,
    /// The prompt text used for this step (for deterministic replay)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt_used: Option<String>,
    /// Issues summary (found and fixed counts)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issues_summary: Option<IssuesSummary>,
}

impl ExecutionStep {
    /// Create a new execution step.
    ///
    /// # Performance Note
    ///
    /// For optimal memory usage, use `new_with_pool` to intern repeated phase
    /// and agent names via a `StringPool`. This constructor creates new Arc<str>
    /// allocations for each call.
    #[must_use]
    pub fn new(phase: &str, iteration: u32, step_type: &str, outcome: StepOutcome) -> Self {
        Self {
            phase: Arc::from(phase),
            iteration,
            step_type: Box::from(step_type),
            timestamp: timestamp(),
            outcome,
            agent: None,
            duration_secs: None,
            checkpoint_saved_at: None,
            git_commit_oid: None,
            modified_files_detail: None,
            prompt_used: None,
            issues_summary: None,
        }
    }

    /// Create a new execution step using a `StringPool` for interning.
    ///
    /// This is the preferred constructor when creating many `ExecutionSteps`,
    /// as it reduces memory usage by sharing allocations for repeated phase
    /// and agent names.
    pub fn new_with_pool(
        phase: &str,
        iteration: u32,
        step_type: &str,
        outcome: StepOutcome,
        pool: crate::checkpoint::StringPool,
    ) -> (Self, crate::checkpoint::StringPool) {
        let (pool, phase_arc) = pool.intern_str(phase);
        (
            Self {
                phase: phase_arc,
                iteration,
                step_type: Box::from(step_type),
                timestamp: timestamp(),
                outcome,
                agent: None,
                duration_secs: None,
                checkpoint_saved_at: None,
                git_commit_oid: None,
                modified_files_detail: None,
                prompt_used: None,
                issues_summary: None,
            },
            pool,
        )
    }

    /// Set the agent that executed this step.
    #[must_use]
    pub fn with_agent(mut self, agent: &str) -> Self {
        self.agent = Some(Arc::from(agent));
        self
    }

    /// Set the agent using a `StringPool` for interning.
    #[must_use]
    pub fn with_agent_pooled(
        mut self,
        agent: &str,
        pool: crate::checkpoint::StringPool,
    ) -> (Self, crate::checkpoint::StringPool) {
        let (pool, agent_arc) = pool.intern_str(agent);
        self.agent = Some(agent_arc);
        (self, pool)
    }

    /// Set the duration of this step.
    #[must_use]
    pub const fn with_duration(mut self, duration_secs: u64) -> Self {
        self.duration_secs = Some(duration_secs);
        self
    }

    /// Set the git commit OID created during this step.
    #[must_use]
    pub fn with_git_commit_oid(mut self, oid: &str) -> Self {
        self.git_commit_oid = Some(oid.to_string());
        self
    }
}

include!("execution_history/file_snapshot.rs");

/// Execution history tracking.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ExecutionHistory {
    /// All execution steps in order
    pub steps: VecDeque<ExecutionStep>,
    /// File snapshots for key files at checkpoint time
    pub file_snapshots: HashMap<String, FileSnapshot>,
}

impl ExecutionHistory {
    /// Execution history must be bounded.
    ///
    /// The historical unbounded `add_step` API is intentionally not available in
    /// non-test builds to avoid reintroducing unbounded growth.
    ///
    /// ```compile_fail
    /// use ralph_workflow::checkpoint::ExecutionHistory;
    /// use ralph_workflow::checkpoint::execution_history::{ExecutionStep, StepOutcome};
    ///
    /// let mut history = ExecutionHistory::new();
    /// let step = ExecutionStep::new("Development", 0, "dev_run", StepOutcome::success(None, vec![]));
    ///
    /// // Unbounded push is not part of the public API.
    /// history.add_step(step);
    /// ```
    /// Create a new execution history.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Add an execution step with explicit bounding (preferred method).
    ///
    /// This is the preferred method that enforces bounded memory growth.
    /// Use this to prevent unbounded growth.
    #[must_use]
    pub fn add_step_bounded(&mut self, step: ExecutionStep, limit: usize) -> &mut Self {
        let drop_count = self.steps.len().saturating_sub(limit.saturating_sub(1));
        self.steps = self
            .steps
            .iter()
            .skip(drop_count)
            .chain(std::iter::once(&step))
            .cloned()
            .collect();
        self
    }

    /// Clone this execution history while enforcing a hard step limit.
    ///
    /// This is intended for resume paths where a legacy checkpoint may contain an
    /// oversized `steps` buffer. Cloning only the tail avoids allocating memory
    /// proportional to the checkpoint's full history.
    #[must_use]
    pub fn clone_bounded(&self, limit: usize) -> Self {
        if limit == 0 {
            return Self {
                steps: VecDeque::new(),
                file_snapshots: self.file_snapshots.clone(),
            };
        }

        let len = self.steps.len();
        if len <= limit {
            return self.clone();
        }

        let keep_from = len.saturating_sub(limit);
        let steps: VecDeque<_> = self.steps.iter().skip(keep_from).cloned().collect();
        Self {
            steps,
            file_snapshots: self.file_snapshots.clone(),
        }
    }
}

#[cfg(test)]
mod tests;