use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum VerificationStep {
Shell {
#[serde(default)]
name: Option<String>,
command: String,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
expect_output_contains: Vec<String>,
#[serde(default)]
expect_files_glob: Vec<String>,
},
FileExists {
#[serde(default)]
name: Option<String>,
path: String,
#[serde(default)]
glob: bool,
},
Url {
#[serde(default)]
name: Option<String>,
url: String,
#[serde(default = "default_http_method")]
method: String,
#[serde(default = "default_http_status")]
expect_status: u16,
#[serde(default)]
expect_body_contains: Vec<String>,
#[serde(default = "default_http_timeout_secs")]
timeout_secs: u64,
},
}
fn default_http_method() -> String {
"GET".to_string()
}
fn default_http_status() -> u16 {
200
}
fn default_http_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserStory {
pub id: String,
pub title: String,
pub description: String,
#[serde(default)]
pub acceptance_criteria: Vec<String>,
#[serde(default)]
pub verification_steps: Vec<VerificationStep>,
#[serde(default)]
pub passes: bool,
#[serde(default = "default_priority")]
pub priority: u8,
#[serde(default, alias = "dependencies")]
pub depends_on: Vec<String>,
#[serde(default = "default_complexity")]
pub complexity: u8,
}
fn default_priority() -> u8 {
1
}
fn default_complexity() -> u8 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prd {
pub project: String,
pub feature: String,
#[serde(default)]
pub branch_name: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub user_stories: Vec<UserStory>,
#[serde(default)]
pub technical_requirements: Vec<String>,
#[serde(default)]
pub quality_checks: QualityChecks,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub updated_at: String,
}
fn default_version() -> String {
"1.0".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QualityChecks {
#[serde(default)]
pub typecheck: Option<String>,
#[serde(default)]
pub test: Option<String>,
#[serde(default)]
pub lint: Option<String>,
#[serde(default)]
pub build: Option<String>,
}
impl Prd {
pub async fn load(path: &PathBuf) -> anyhow::Result<Self> {
let content = tokio::fs::read_to_string(path).await?;
let prd: Prd = serde_json::from_str(&content)?;
Ok(prd)
}
pub async fn save(&self, path: &PathBuf) -> anyhow::Result<()> {
let content = serde_json::to_string_pretty(self)?;
tokio::fs::write(path, content).await?;
Ok(())
}
pub fn next_story(&self) -> Option<&UserStory> {
self.user_stories
.iter()
.filter(|s| !s.passes)
.filter(|s| self.dependencies_met(&s.depends_on))
.min_by_key(|s| (s.priority, s.complexity))
}
fn dependencies_met(&self, deps: &[String]) -> bool {
deps.iter().all(|dep_id| {
self.user_stories
.iter()
.find(|s| s.id == *dep_id)
.map(|s| s.passes)
.unwrap_or(true)
})
}
pub fn passed_count(&self) -> usize {
self.user_stories.iter().filter(|s| s.passes).count()
}
pub fn is_complete(&self) -> bool {
self.user_stories.iter().all(|s| s.passes)
}
pub fn mark_passed(&mut self, story_id: &str) {
if let Some(story) = self.user_stories.iter_mut().find(|s| s.id == *story_id) {
story.passes = true;
}
}
pub fn ready_stories(&self) -> Vec<&UserStory> {
self.user_stories
.iter()
.filter(|s| !s.passes)
.filter(|s| self.dependencies_met(&s.depends_on))
.collect()
}
pub fn stages(&self) -> Vec<Vec<&UserStory>> {
let mut stages: Vec<Vec<&UserStory>> = Vec::new();
let mut completed: std::collections::HashSet<String> = self
.user_stories
.iter()
.filter(|s| s.passes)
.map(|s| s.id.clone())
.collect();
let mut remaining: Vec<&UserStory> =
self.user_stories.iter().filter(|s| !s.passes).collect();
while !remaining.is_empty() {
let (ready, not_ready): (Vec<_>, Vec<_>) = remaining
.into_iter()
.partition(|s| s.depends_on.iter().all(|dep| completed.contains(dep)));
if ready.is_empty() {
if !not_ready.is_empty() {
stages.push(not_ready);
}
break;
}
for story in &ready {
completed.insert(story.id.clone());
}
stages.push(ready);
remaining = not_ready;
}
stages
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RalphState {
pub prd: Prd,
pub current_iteration: usize,
pub max_iterations: usize,
pub status: RalphStatus,
#[serde(default)]
pub progress_log: Vec<ProgressEntry>,
pub prd_path: PathBuf,
pub working_dir: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RalphStatus {
Pending,
Running,
Completed,
MaxIterations,
Stopped,
QualityFailed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressEntry {
pub story_id: String,
pub iteration: usize,
pub status: String,
#[serde(default)]
pub learnings: Vec<String>,
#[serde(default)]
pub files_changed: Vec<String>,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RalphConfig {
#[serde(default = "default_prd_path")]
pub prd_path: String,
#[serde(default = "default_max_iterations")]
pub max_iterations: usize,
#[serde(default = "default_progress_path")]
pub progress_path: String,
#[serde(default = "default_auto_commit")]
pub auto_commit: bool,
#[serde(default = "default_quality_checks_enabled")]
pub quality_checks_enabled: bool,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub use_rlm: bool,
#[serde(default = "default_parallel_enabled")]
pub parallel_enabled: bool,
#[serde(default = "default_max_concurrent_stories")]
pub max_concurrent_stories: usize,
#[serde(default = "default_worktree_enabled")]
pub worktree_enabled: bool,
#[serde(default = "default_story_timeout_secs")]
pub story_timeout_secs: u64,
#[serde(default = "default_max_steps_per_story")]
pub max_steps_per_story: usize,
#[serde(default = "default_conflict_timeout_secs")]
pub conflict_timeout_secs: u64,
#[serde(default)]
pub relay_enabled: bool,
#[serde(default = "default_relay_max_agents")]
pub relay_max_agents: usize,
#[serde(default = "default_relay_max_rounds")]
pub relay_max_rounds: usize,
}
fn default_prd_path() -> String {
"prd.json".to_string()
}
fn default_max_iterations() -> usize {
10
}
fn default_progress_path() -> String {
"progress.txt".to_string()
}
fn default_auto_commit() -> bool {
false
}
fn default_quality_checks_enabled() -> bool {
true
}
fn default_parallel_enabled() -> bool {
true
}
fn default_max_concurrent_stories() -> usize {
100
}
fn default_worktree_enabled() -> bool {
true
}
fn default_story_timeout_secs() -> u64 {
300
}
fn default_max_steps_per_story() -> usize {
50
}
fn default_conflict_timeout_secs() -> u64 {
120
}
fn default_relay_max_agents() -> usize {
8
}
fn default_relay_max_rounds() -> usize {
3
}
impl Default for RalphConfig {
fn default() -> Self {
Self {
prd_path: default_prd_path(),
max_iterations: default_max_iterations(),
progress_path: default_progress_path(),
auto_commit: default_auto_commit(),
quality_checks_enabled: default_quality_checks_enabled(),
model: None,
use_rlm: true,
parallel_enabled: default_parallel_enabled(),
max_concurrent_stories: default_max_concurrent_stories(),
worktree_enabled: default_worktree_enabled(),
story_timeout_secs: default_story_timeout_secs(),
max_steps_per_story: default_max_steps_per_story(),
conflict_timeout_secs: default_conflict_timeout_secs(),
relay_enabled: true,
relay_max_agents: default_relay_max_agents(),
relay_max_rounds: default_relay_max_rounds(),
}
}
}