Skip to main content

enact_core/workflow/
story_loop.rs

1//! Story Loop - Iterate over planned stories with per-item verification
2//!
3//! Implements Antfarm's story loop pattern:
4//! - Iterate over planned stories
5//! - Optional fresh-session per story
6//! - Per-story verification
7//! - Independent retry counters
8
9use crate::workflow::contract::{ContractParser, ParsedOutput, StepStatus};
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14/// Story definition
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Story {
17    /// Story identifier
18    pub id: String,
19    /// Story title
20    pub title: String,
21    /// Story description
22    pub description: String,
23    /// Acceptance criteria
24    #[serde(default)]
25    pub acceptance_criteria: Vec<String>,
26    /// Test criteria
27    #[serde(default)]
28    pub test_criteria: Vec<String>,
29    /// Dependencies (story IDs that must be completed first)
30    #[serde(default)]
31    pub depends_on: Vec<String>,
32    /// Estimated effort
33    #[serde(default)]
34    pub effort: Option<String>,
35    /// Whether this story is completed
36    #[serde(default)]
37    pub completed: bool,
38    /// Number of retry attempts
39    #[serde(default)]
40    pub retry_count: u32,
41    /// Verification result
42    #[serde(default)]
43    pub verified: Option<bool>,
44    /// Feedback from verifier
45    #[serde(default)]
46    pub verify_feedback: Option<String>,
47}
48
49/// Story loop configuration
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct StoryLoopConfig {
52    /// Field containing the stories array (e.g., "plan.STORIES_JSON")
53    pub over: String,
54    /// Completion condition
55    #[serde(default)]
56    pub completion: CompletionCondition,
57    /// Whether to use fresh session for each story
58    #[serde(default)]
59    pub fresh_session: bool,
60    /// Whether to verify each story
61    #[serde(default)]
62    pub verify_each: bool,
63    /// Verification step ID (if verify_each is true)
64    #[serde(default)]
65    pub verify_step: Option<String>,
66}
67
68/// Completion conditions for story loop
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70#[serde(rename_all = "snake_case")]
71pub enum CompletionCondition {
72    /// All stories must be done
73    #[default]
74    AllDone,
75    /// At least one story done
76    AtLeastOne,
77    /// Specific number of stories
78    Count(usize),
79    /// Percentage of stories
80    Percentage(f32),
81}
82
83/// Story loop state
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct StoryLoopState {
86    /// All stories
87    pub stories: Vec<Story>,
88    /// Current story index
89    pub current_index: usize,
90    /// Completed story IDs
91    pub completed_ids: Vec<String>,
92    /// Stories pending verification
93    pub pending_verification: Vec<String>,
94    /// Loop iteration count (safety)
95    pub iteration_count: usize,
96    /// Maximum iterations (prevent infinite loops)
97    pub max_iterations: usize,
98}
99
100impl StoryLoopState {
101    /// Create new story loop state
102    pub fn new(stories: Vec<Story>) -> Self {
103        Self {
104            max_iterations: stories.len() * 3, // Allow 3 attempts per story
105            stories,
106            current_index: 0,
107            completed_ids: vec![],
108            pending_verification: vec![],
109            iteration_count: 0,
110        }
111    }
112
113    /// Get current story
114    pub fn current_story(&self) -> Option<&Story> {
115        self.stories.get(self.current_index)
116    }
117
118    /// Get current story (mutable)
119    pub fn current_story_mut(&mut self) -> Option<&mut Story> {
120        self.stories.get_mut(self.current_index)
121    }
122
123    /// Mark current story as completed
124    pub fn mark_completed(&mut self) -> Result<()> {
125        if let Some(story) = self.current_story() {
126            let id = story.id.clone();
127            if let Some(story) = self.stories.iter_mut().find(|s| s.id == id) {
128                story.completed = true;
129                if !self.completed_ids.contains(&id) {
130                    self.completed_ids.push(id);
131                }
132            }
133        }
134        Ok(())
135    }
136
137    /// Mark current story for retry with feedback
138    pub fn mark_retry(&mut self, feedback: &str) -> Result<()> {
139        if let Some(story) = self.current_story_mut() {
140            story.retry_count += 1;
141            story.verify_feedback = Some(feedback.to_string());
142        }
143        Ok(())
144    }
145
146    /// Move to next story
147    pub fn next_story(&mut self) -> bool {
148        self.iteration_count += 1;
149
150        // Find next incomplete story
151        for i in (self.current_index + 1)..self.stories.len() {
152            if !self.stories[i].completed {
153                self.current_index = i;
154                return true;
155            }
156        }
157
158        // Check if we need to retry any stories
159        for i in 0..self.stories.len() {
160            if !self.stories[i].completed && self.stories[i].retry_count < 2 {
161                self.current_index = i;
162                return true;
163            }
164        }
165
166        false
167    }
168
169    /// Check if completion condition is met
170    pub fn is_complete(&self, condition: &CompletionCondition) -> bool {
171        let completed = self.completed_ids.len();
172        let total = self.stories.len();
173
174        match condition {
175            CompletionCondition::AllDone => completed >= total,
176            CompletionCondition::AtLeastOne => completed >= 1,
177            CompletionCondition::Count(n) => completed >= *n,
178            CompletionCondition::Percentage(p) => {
179                if total == 0 {
180                    true
181                } else {
182                    (completed as f32 / total as f32) >= (*p / 100.0)
183                }
184            }
185        }
186    }
187
188    /// Check if we've exceeded max iterations
189    pub fn should_stop(&self) -> bool {
190        self.iteration_count >= self.max_iterations
191    }
192
193    /// Get completion percentage
194    pub fn completion_percentage(&self) -> f32 {
195        if self.stories.is_empty() {
196            100.0
197        } else {
198            (self.completed_ids.len() as f32 / self.stories.len() as f32) * 100.0
199        }
200    }
201
202    /// Serialize stories to JSON
203    pub fn stories_json(&self) -> Result<String> {
204        serde_json::to_string(&self.stories).context("Failed to serialize stories")
205    }
206
207    /// Get context variables for current iteration
208    pub fn context_variables(&self) -> HashMap<String, String> {
209        let mut vars = HashMap::new();
210
211        vars.insert("stories_count".to_string(), self.stories.len().to_string());
212        vars.insert(
213            "completed_count".to_string(),
214            self.completed_ids.len().to_string(),
215        );
216        vars.insert(
217            "remaining_count".to_string(),
218            (self.stories.len() - self.completed_ids.len()).to_string(),
219        );
220        vars.insert(
221            "completion_percentage".to_string(),
222            format!("{:.1}", self.completion_percentage()),
223        );
224
225        if let Some(story) = self.current_story() {
226            vars.insert("current_story_id".to_string(), story.id.clone());
227            vars.insert("current_story_title".to_string(), story.title.clone());
228            vars.insert(
229                "current_story".to_string(),
230                serde_json::to_string(story).unwrap_or_default(),
231            );
232            vars.insert(
233                "current_story_description".to_string(),
234                story.description.clone(),
235            );
236
237            if let Some(feedback) = &story.verify_feedback {
238                vars.insert("verify_feedback".to_string(), feedback.clone());
239            }
240        }
241
242        vars.insert(
243            "completed_stories".to_string(),
244            serde_json::to_string(&self.completed_ids).unwrap_or_default(),
245        );
246
247        vars.insert(
248            "stories_json".to_string(),
249            self.stories_json().unwrap_or_default(),
250        );
251
252        vars
253    }
254}
255
256/// Story loop executor
257pub struct StoryLoopExecutor;
258
259impl StoryLoopExecutor {
260    /// Execute a story loop
261    pub async fn execute_loop<F, Fut>(
262        config: &StoryLoopConfig,
263        stories: Vec<Story>,
264        mut step_fn: F,
265    ) -> Result<StoryLoopResult>
266    where
267        F: FnMut(StoryLoopState) -> Fut,
268        Fut: std::future::Future<Output = Result<ParsedOutput>>,
269    {
270        let mut state = StoryLoopState::new(stories);
271
272        loop {
273            // Check stopping conditions
274            if state.should_stop() {
275                return Ok(StoryLoopResult {
276                    success: false,
277                    state,
278                    reason: Some("Max iterations exceeded".to_string()),
279                });
280            }
281
282            if state.is_complete(&config.completion) {
283                return Ok(StoryLoopResult {
284                    success: true,
285                    state,
286                    reason: None,
287                });
288            }
289
290            // Check if there's a current story
291            if state.current_story().is_none() {
292                return Ok(StoryLoopResult {
293                    success: false,
294                    state,
295                    reason: Some("No more stories to process".to_string()),
296                });
297            }
298
299            // Execute the step
300            let output = step_fn(state.clone()).await?;
301
302            // Handle the result
303            match output.status {
304                StepStatus::Done => {
305                    state.mark_completed()?;
306
307                    // If verification is enabled, add to pending
308                    if config.verify_each {
309                        if let Some(story) = state.current_story() {
310                            state.pending_verification.push(story.id.clone());
311                        }
312                    }
313
314                    state.next_story();
315                }
316                StepStatus::Retry => {
317                    let feedback = ContractParser::get_feedback(&output.raw_output, "ISSUES")
318                        .unwrap_or_else(|| "Retry requested".to_string());
319
320                    // Get story ID before mutable borrow
321                    let current_story_id = state.current_story().map(|s| s.id.clone());
322
323                    state.mark_retry(&feedback)?;
324
325                    // Check if max retries reached
326                    if let Some(story_id) = current_story_id {
327                        if let Some(story) = state.stories.iter().find(|s| s.id == story_id) {
328                            if story.retry_count >= 2 {
329                                return Ok(StoryLoopResult {
330                                    success: false,
331                                    state,
332                                    reason: Some(format!(
333                                        "Max retries reached for story: {}",
334                                        story_id
335                                    )),
336                                });
337                            }
338                        }
339                    }
340                }
341                StepStatus::Blocked => {
342                    let blocked_story_id = state
343                        .current_story()
344                        .map(|s| s.id.clone())
345                        .unwrap_or_default();
346                    return Ok(StoryLoopResult {
347                        success: false,
348                        state,
349                        reason: Some(format!("Story blocked: {}", blocked_story_id)),
350                    });
351                }
352            }
353        }
354    }
355}
356
357/// Story loop execution result
358#[derive(Debug, Clone)]
359pub struct StoryLoopResult {
360    /// Whether the loop completed successfully
361    pub success: bool,
362    /// Final state
363    pub state: StoryLoopState,
364    /// Reason for failure (if any)
365    pub reason: Option<String>,
366}
367
368/// Parse stories from JSON string
369pub fn parse_stories(json_str: &str) -> Result<Vec<Story>> {
370    serde_json::from_str(json_str).context("Failed to parse stories JSON")
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn create_test_stories() -> Vec<Story> {
378        vec![
379            Story {
380                id: "story-1".to_string(),
381                title: "First Story".to_string(),
382                description: "Implement feature A".to_string(),
383                acceptance_criteria: vec!["Feature A works".to_string()],
384                test_criteria: vec!["Test A passes".to_string()],
385                depends_on: vec![],
386                effort: Some("small".to_string()),
387                completed: false,
388                retry_count: 0,
389                verified: None,
390                verify_feedback: None,
391            },
392            Story {
393                id: "story-2".to_string(),
394                title: "Second Story".to_string(),
395                description: "Implement feature B".to_string(),
396                acceptance_criteria: vec!["Feature B works".to_string()],
397                test_criteria: vec!["Test B passes".to_string()],
398                depends_on: vec!["story-1".to_string()],
399                effort: Some("medium".to_string()),
400                completed: false,
401                retry_count: 0,
402                verified: None,
403                verify_feedback: None,
404            },
405        ]
406    }
407
408    #[test]
409    fn test_story_loop_state() {
410        let stories = create_test_stories();
411        let state = StoryLoopState::new(stories);
412
413        assert_eq!(state.stories.len(), 2);
414        assert_eq!(state.current_index, 0);
415        assert!(state.current_story().is_some());
416        assert_eq!(state.current_story().unwrap().id, "story-1");
417    }
418
419    #[test]
420    fn test_mark_completed() {
421        let stories = create_test_stories();
422        let mut state = StoryLoopState::new(stories);
423
424        state.mark_completed().unwrap();
425        assert_eq!(state.completed_ids.len(), 1);
426        assert!(state.stories[0].completed);
427    }
428
429    #[test]
430    fn test_next_story() {
431        let stories = create_test_stories();
432        let mut state = StoryLoopState::new(stories);
433
434        // First story
435        assert_eq!(state.current_story().unwrap().id, "story-1");
436
437        // Mark first as complete, move to second
438        state.mark_completed().unwrap();
439        assert!(state.next_story());
440        assert_eq!(state.current_story().unwrap().id, "story-2");
441    }
442
443    #[test]
444    fn test_completion_conditions() {
445        let stories = create_test_stories();
446        let mut state = StoryLoopState::new(stories);
447
448        // Not complete with 0/2 done
449        assert!(!state.is_complete(&CompletionCondition::AllDone));
450        assert!(!state.is_complete(&CompletionCondition::Count(1)));
451
452        // Complete one
453        state.mark_completed().unwrap();
454        assert!(state.is_complete(&CompletionCondition::Count(1)));
455        assert!(!state.is_complete(&CompletionCondition::AllDone));
456
457        // Complete second
458        state.next_story();
459        state.mark_completed().unwrap();
460        assert!(state.is_complete(&CompletionCondition::AllDone));
461    }
462
463    #[test]
464    fn test_context_variables() {
465        let stories = create_test_stories();
466        let state = StoryLoopState::new(stories);
467
468        let vars = state.context_variables();
469        assert_eq!(vars.get("stories_count").unwrap(), "2");
470        assert_eq!(vars.get("completed_count").unwrap(), "0");
471        assert!(vars.contains_key("current_story"));
472        assert!(vars.contains_key("stories_json"));
473    }
474
475    #[test]
476    fn test_parse_stories() {
477        let json = r#"[
478            {
479                "id": "story-1",
480                "title": "Test Story",
481                "description": "A test story",
482                "acceptance_criteria": ["It works"],
483                "completed": false
484            }
485        ]"#;
486
487        let stories = parse_stories(json).unwrap();
488        assert_eq!(stories.len(), 1);
489        assert_eq!(stories[0].id, "story-1");
490    }
491}