Skip to main content

ralph_workflow/reducer/
effect.rs

1//! Effect types and handlers for side effects.
2//!
3//! Effects represent side-effect operations that the reducer triggers.
4//! Effect handlers execute effects and emit events.
5
6use crate::agents::AgentRole;
7use crate::phases::PhaseContext;
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10
11use super::event::{CheckpointTrigger, ConflictStrategy, PipelineEvent, RebasePhase};
12use super::ui_event::UIEvent;
13
14/// Effects represent side-effect operations.
15///
16/// The reducer determines which effect to execute next based on state.
17/// Effect handlers execute effects and emit events.
18#[derive(Clone, Serialize, Deserialize, Debug)]
19pub enum Effect {
20    AgentInvocation {
21        role: AgentRole,
22        agent: String,
23        model: Option<String>,
24        prompt: String,
25    },
26
27    InitializeAgentChain {
28        role: AgentRole,
29    },
30
31    GeneratePlan {
32        iteration: u32,
33    },
34
35    RunDevelopmentIteration {
36        iteration: u32,
37    },
38
39    RunReviewPass {
40        pass: u32,
41    },
42
43    RunFixAttempt {
44        pass: u32,
45    },
46
47    RunRebase {
48        phase: RebasePhase,
49        target_branch: String,
50    },
51
52    ResolveRebaseConflicts {
53        strategy: ConflictStrategy,
54    },
55
56    GenerateCommitMessage,
57
58    CreateCommit {
59        message: String,
60    },
61
62    SkipCommit {
63        reason: String,
64    },
65
66    ValidateFinalState,
67
68    SaveCheckpoint {
69        trigger: CheckpointTrigger,
70    },
71
72    CleanupContext,
73
74    /// Restore PROMPT.md write permissions after pipeline completion.
75    ///
76    /// This effect is emitted during the Finalizing phase to restore
77    /// write permissions on PROMPT.md so users can edit it normally
78    /// after Ralph exits.
79    RestorePromptPermissions,
80}
81
82/// Result of executing an effect.
83///
84/// Contains both the PipelineEvent (for reducer) and optional UIEvents (for display).
85/// This separation keeps UI concerns out of the reducer while allowing handlers
86/// to emit rich feedback during execution.
87#[derive(Clone, Debug)]
88pub struct EffectResult {
89    /// Event for reducer (affects state).
90    pub event: PipelineEvent,
91    /// UI events for display (do not affect state).
92    pub ui_events: Vec<UIEvent>,
93}
94
95impl EffectResult {
96    /// Create result with just a pipeline event (no UI events).
97    pub fn event(event: PipelineEvent) -> Self {
98        Self {
99            event,
100            ui_events: Vec::new(),
101        }
102    }
103
104    /// Create result with pipeline event and UI events.
105    pub fn with_ui(event: PipelineEvent, ui_events: Vec<UIEvent>) -> Self {
106        Self { event, ui_events }
107    }
108
109    /// Add a UI event to the result.
110    pub fn with_ui_event(mut self, ui_event: UIEvent) -> Self {
111        self.ui_events.push(ui_event);
112        self
113    }
114}
115
116/// Trait for executing effects.
117///
118/// Returns EffectResult containing both PipelineEvent (for state) and
119/// UIEvents (for display). This allows mocking in tests.
120pub trait EffectHandler<'ctx> {
121    fn execute(&mut self, effect: Effect, ctx: &mut PhaseContext<'_>) -> Result<EffectResult>;
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::reducer::event::PipelinePhase;
128
129    #[test]
130    fn test_effect_serialization() {
131        let effect = Effect::AgentInvocation {
132            role: AgentRole::Developer,
133            agent: "claude".to_string(),
134            model: None,
135            prompt: "test".to_string(),
136        };
137
138        let json = serde_json::to_string(&effect).unwrap();
139        let deserialized: Effect = serde_json::from_str(&json).unwrap();
140
141        match deserialized {
142            Effect::AgentInvocation {
143                role,
144                agent,
145                model,
146                prompt,
147            } => {
148                assert_eq!(role, AgentRole::Developer);
149                assert_eq!(agent, "claude");
150                assert!(model.is_none());
151                assert_eq!(prompt, "test");
152            }
153            _ => panic!("Expected AgentInvocation effect"),
154        }
155    }
156
157    #[test]
158    fn test_effect_result_event_only() {
159        let event = PipelineEvent::PipelineStarted;
160        let result = EffectResult::event(event.clone());
161
162        assert!(matches!(result.event, PipelineEvent::PipelineStarted));
163        assert!(result.ui_events.is_empty());
164    }
165
166    #[test]
167    fn test_effect_result_with_ui() {
168        let event = PipelineEvent::DevelopmentIterationCompleted {
169            iteration: 1,
170            output_valid: true,
171        };
172        let ui_events = vec![UIEvent::IterationProgress {
173            current: 1,
174            total: 3,
175        }];
176
177        let result = EffectResult::with_ui(event, ui_events);
178
179        assert!(matches!(
180            result.event,
181            PipelineEvent::DevelopmentIterationCompleted { .. }
182        ));
183        assert_eq!(result.ui_events.len(), 1);
184        assert!(matches!(
185            result.ui_events[0],
186            UIEvent::IterationProgress {
187                current: 1,
188                total: 3
189            }
190        ));
191    }
192
193    #[test]
194    fn test_effect_result_with_ui_event_builder() {
195        let event = PipelineEvent::PlanGenerationCompleted {
196            iteration: 1,
197            valid: true,
198        };
199
200        let result = EffectResult::event(event).with_ui_event(UIEvent::PhaseTransition {
201            from: Some(PipelinePhase::Planning),
202            to: PipelinePhase::Development,
203        });
204
205        assert_eq!(result.ui_events.len(), 1);
206        assert!(matches!(
207            result.ui_events[0],
208            UIEvent::PhaseTransition { .. }
209        ));
210    }
211
212    #[test]
213    fn test_effect_result_multiple_ui_events() {
214        let event = PipelineEvent::DevelopmentIterationCompleted {
215            iteration: 2,
216            output_valid: true,
217        };
218
219        let result = EffectResult::event(event)
220            .with_ui_event(UIEvent::IterationProgress {
221                current: 2,
222                total: 5,
223            })
224            .with_ui_event(UIEvent::AgentActivity {
225                agent: "claude".to_string(),
226                message: "Completed iteration".to_string(),
227            });
228
229        assert_eq!(result.ui_events.len(), 2);
230    }
231}