Skip to main content

ralph_workflow/app/runner/
setup_helpers.rs

1use crate::agents::AgentRegistry;
2use crate::checkpoint::PipelinePhase;
3use crate::cli::{create_prompt_from_template, prompt_template_selection};
4use crate::logger::{Colors, Logger};
5use crate::workspace::Workspace;
6
7use super::super::effect::{AppEffect, AppEffectHandler, AppEffectResult};
8use super::super::effectful;
9use super::super::validation::{validate_agent_commands, validate_can_commit};
10
11// Setup helpers for agent validation and pipeline preparation.
12//
13// This module contains:
14// - validate_and_setup_agents: Validates agent commands and sets up git repo
15// - setup_git_and_prompt_file: Creates PROMPT.md from template if needed
16// - Interrupt context management for checkpoint saving
17// - Configuration validation helpers
18
19/// Parameters for agent validation and setup.
20pub struct AgentSetupParams<'a> {
21    pub(crate) config: &'a crate::config::Config,
22    pub(crate) registry: &'a AgentRegistry,
23    pub(crate) developer_agent: &'a str,
24    pub(crate) reviewer_agent: &'a str,
25    pub(crate) config_path: &'a std::path::Path,
26    pub(crate) colors: Colors,
27    pub(crate) logger: &'a Logger,
28    /// If Some, use this path as the working directory without discovering the repo root
29    /// or changing the global CWD. This enables test parallelism.
30    pub(crate) working_dir_override: Option<&'a std::path::Path>,
31}
32
33/// Validates agent commands and workflow capability, then sets up git repo and PROMPT.md.
34///
35/// Returns `Some(repo_root)` if setup succeeded and should continue.
36/// Returns `None` if the user declined PROMPT.md creation (to exit early).
37pub fn validate_and_setup_agents<H: AppEffectHandler>(
38    params: &AgentSetupParams<'_>,
39    handler: &mut H,
40) -> anyhow::Result<Option<std::path::PathBuf>> {
41    let AgentSetupParams {
42        config,
43        registry,
44        developer_agent,
45        reviewer_agent,
46        config_path,
47        colors,
48        logger,
49        working_dir_override,
50    } = params;
51    // Validate agent commands exist
52    validate_agent_commands(
53        config,
54        registry,
55        developer_agent,
56        reviewer_agent,
57        config_path,
58    )?;
59
60    // Validate agents are workflow-capable
61    validate_can_commit(
62        config,
63        registry,
64        developer_agent,
65        reviewer_agent,
66        config_path,
67    )?;
68
69    // Determine repo root - use override if provided (for testing), otherwise discover
70    let repo_root = if let Some(override_dir) = working_dir_override {
71        // Testing mode: use provided directory and change CWD to it via handler
72        let result = handler.execute(AppEffect::SetCurrentDir {
73            path: override_dir.to_path_buf(),
74        });
75        if let AppEffectResult::Error(e) = result {
76            anyhow::bail!("Failed to set working directory: {e}");
77        }
78        override_dir.to_path_buf()
79    } else {
80        // Production mode: discover repo root and change CWD via handler
81        let require_result = handler.execute(AppEffect::GitRequireRepo);
82        if let AppEffectResult::Error(e) = require_result {
83            anyhow::bail!("Not in a git repository: {e}");
84        }
85
86        let root_result = handler.execute(AppEffect::GitGetRepoRoot);
87        let root = match root_result {
88            AppEffectResult::Path(p) => p,
89            AppEffectResult::Error(e) => {
90                anyhow::bail!("Failed to get repo root: {e}");
91            }
92            _ => anyhow::bail!("Unexpected result from GitGetRepoRoot"),
93        };
94
95        let set_result = handler.execute(AppEffect::SetCurrentDir { path: root.clone() });
96        if let AppEffectResult::Error(e) = set_result {
97            anyhow::bail!("Failed to set working directory: {e}");
98        }
99        root
100    };
101
102    // Set up PROMPT.md if needed (may return None to exit early)
103    let should_continue = setup_git_and_prompt_file(config, *colors, logger, handler)?;
104    if should_continue.is_none() {
105        return Ok(None);
106    }
107
108    Ok(Some(repo_root))
109}
110
111/// In interactive mode, prompts to create PROMPT.md from a template before `ensure_files()`.
112///
113/// Returns `Ok(Some(()))` if setup succeeded and should continue.
114/// Returns `Ok(None)` if the user declined PROMPT.md creation (to exit early).
115fn setup_git_and_prompt_file<H: AppEffectHandler>(
116    config: &crate::config::Config,
117    colors: Colors,
118    logger: &Logger,
119    handler: &mut H,
120) -> anyhow::Result<Option<()>> {
121    let prompt_exists =
122        effectful::check_prompt_exists_effectful(handler).map_err(|e| anyhow::anyhow!("{e}"))?;
123
124    // In interactive mode, prompt to create PROMPT.md from a template BEFORE ensure_files().
125    // If the user declines (or we can't prompt), exit without creating a placeholder PROMPT.md.
126    if config.behavior.interactive && !prompt_exists {
127        if let Some(template_name) = prompt_template_selection(colors) {
128            create_prompt_from_template(&template_name, colors)?;
129            logger.info(""); // Empty line for spacing
130            logger.info(
131                "PROMPT.md created. Please edit it with your task details, then run ralph again.",
132            );
133            logger.info("Tip: Edit PROMPT.md, then run: ralph");
134            return Ok(None);
135        }
136        logger.info(""); // Empty line for spacing
137        logger.error("PROMPT.md not found in current directory.");
138        logger.warn("PROMPT.md is required to run the Ralph pipeline.");
139        logger.info(""); // Empty line for spacing
140        logger.info("To get started:");
141        logger.info("  ralph --init                    # Smart setup wizard");
142        logger.info("  ralph --init bug-fix             # Create from Work Guide");
143        logger.info("  ralph --list-work-guides          # See all Work Guides");
144        logger.info(""); // Empty line for spacing
145        return Ok(None);
146    }
147
148    // Non-interactive mode: show helpful error if PROMPT.md doesn't exist
149    if !prompt_exists {
150        logger.error("PROMPT.md not found in current directory.");
151        logger.warn("PROMPT.md is required to run the Ralph pipeline.");
152        logger.info(""); // Empty line for spacing
153        logger.info("Quick start:");
154        logger.info("  ralph --init                    # Smart setup wizard");
155        logger.info("  ralph --init bug-fix             # Create from Work Guide");
156        logger.info("  ralph --list-work-guides          # See all Work Guides");
157        logger.info(""); // Empty line for spacing
158        logger.info("Use -i flag for interactive mode to be prompted for template selection.");
159        logger.info(""); // Empty line for spacing
160        return Ok(None);
161    }
162
163    Ok(Some(()))
164}
165
166/// Set up the interrupt context with initial pipeline state.
167///
168/// This function initializes the global interrupt context so that if
169/// the user presses Ctrl+C, the interrupt handler can save a checkpoint.
170pub(crate) fn setup_interrupt_context_for_pipeline(
171    phase: PipelinePhase,
172    total_iterations: u32,
173    total_reviewer_passes: u32,
174    execution_history: &crate::checkpoint::ExecutionHistory,
175    prompt_history: &std::collections::HashMap<String, crate::prompts::PromptHistoryEntry>,
176    run_context: &crate::checkpoint::RunContext,
177    workspace: std::sync::Arc<dyn Workspace>,
178) {
179    use crate::interrupt::{set_interrupt_context, InterruptContext};
180
181    // Determine initial iteration based on phase
182    let (iteration, reviewer_pass) = match phase {
183        PipelinePhase::Development => (1, 0),
184        PipelinePhase::Review => (total_iterations, 1),
185        PipelinePhase::PostRebase | PipelinePhase::CommitMessage => {
186            (total_iterations, total_reviewer_passes)
187        }
188        _ => (0, 0),
189    };
190
191    let context = InterruptContext {
192        phase,
193        iteration,
194        total_iterations,
195        reviewer_pass,
196        total_reviewer_passes,
197        run_context: run_context.clone(),
198        execution_history: execution_history.clone(),
199        prompt_history: prompt_history.clone(),
200        workspace,
201    };
202
203    set_interrupt_context(context);
204}
205
206/// Update the interrupt context from the current phase context.
207///
208/// This function should be called after each major phase to keep the
209/// interrupt context up-to-date with the latest execution history.
210///
211/// `prompt_history` reflects the state at the time of the last checkpoint load.
212///
213/// While the reducer event loop is active, Ctrl+C triggers a reducer-driven
214/// checkpoint write from live `PipelineState` (including up-to-date
215/// `prompt_history`). This interrupt context is used only for early interrupts
216/// before the event loop starts.
217pub(crate) fn update_interrupt_context_from_phase(
218    execution_history: &crate::checkpoint::ExecutionHistory,
219    prompt_history: std::collections::HashMap<String, crate::prompts::PromptHistoryEntry>,
220    phase: PipelinePhase,
221    total_iterations: u32,
222    total_reviewer_passes: u32,
223    run_context: &crate::checkpoint::RunContext,
224    workspace: std::sync::Arc<dyn Workspace>,
225) {
226    use crate::interrupt::{set_interrupt_context, InterruptContext};
227
228    // Determine current iteration based on phase
229    let (iteration, reviewer_pass) = match phase {
230        PipelinePhase::Development => {
231            // Estimate iteration from actual runs
232            let iter = run_context.actual_developer_runs.max(1);
233            (iter, 0)
234        }
235        PipelinePhase::Review => (total_iterations, run_context.actual_reviewer_runs.max(1)),
236        PipelinePhase::PostRebase | PipelinePhase::CommitMessage => {
237            (total_iterations, total_reviewer_passes)
238        }
239        _ => (0, 0),
240    };
241
242    let context = InterruptContext {
243        phase,
244        iteration,
245        total_iterations,
246        reviewer_pass,
247        total_reviewer_passes,
248        run_context: run_context.clone(),
249        execution_history: execution_history.clone(),
250        prompt_history,
251        workspace,
252    };
253
254    set_interrupt_context(context);
255}
256
257/// Helper to defer clearing interrupt context until function exit.
258///
259/// Uses a scope guard pattern to ensure the interrupt context is cleared
260/// when the pipeline completes successfully, preventing an "interrupted"
261/// checkpoint from being saved after normal completion.
262pub(crate) const fn defer_clear_interrupt_context() -> InterruptContextGuard {
263    InterruptContextGuard
264}
265
266/// RAII guard for clearing interrupt context on drop.
267///
268/// Ensures the interrupt context is cleared when the guard is dropped,
269/// preventing an "interrupted" checkpoint from being saved after normal
270/// pipeline completion.
271pub(crate) struct InterruptContextGuard;
272
273impl Drop for InterruptContextGuard {
274    fn drop(&mut self) {
275        crate::interrupt::clear_interrupt_context();
276    }
277}