Files
g3/crates/studio/src/sdlc.rs
Dhanji R. Prasanna add8060526 Add studio sdlc command for SDLC maintenance pipeline
Implements a pipeline that orchestrates 7 g3 agents in sequence:
1. euler - dependency graph and hotspots analysis
2. breaker - whitebox exploration and edge-case discovery
3. hopper - deep testing and regression integrity
4. fowler - refactoring to deduplicate and reduce complexity
5. carmack - in-place rewriting for readability and concision
6. lamport - human-readable documentation and validation
7. huffman - semantic compression of memory

Features:
- Commit cursor tracking (--from flag to set starting point)
- Crash recovery (resumes from last incomplete stage)
- Git worktree isolation for all pipeline work
- Visual pipeline display with status icons
- Summary generation saved to .g3/sessions/sdlc/
- Pipeline state persisted to analysis/sdlc/pipeline.json

CLI:
- studio sdlc run [-c N] [--from COMMIT]
- studio sdlc status
- studio sdlc reset

Also adds huffman agent to embedded agents list.
2026-02-05 10:46:10 +11:00

649 lines
21 KiB
Rust

//! SDLC Pipeline - Software Development Life Cycle maintenance pipeline
//!
//! Orchestrates a sequence of g3 agents to maintain and improve the codebase:
//! 1. euler - Dependency graph and hotspots analysis
//! 2. breaker - Whitebox exploration and edge-case discovery
//! 3. hopper - Deep testing and regression integrity
//! 4. fowler - Refactoring to deduplicate and reduce complexity
//! 5. carmack - In-place rewriting for readability and concision
//! 6. lamport - Human-readable documentation and validation
//! 7. huffman - Semantic compression of memory
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
/// Pipeline stage definition
#[derive(Debug, Clone)]
pub struct Stage {
/// Agent name (e.g., "euler")
pub name: &'static str,
/// Human-readable description
pub description: &'static str,
/// What this agent focuses on
pub focus: &'static str,
}
/// The ordered pipeline stages
pub static PIPELINE_STAGES: &[Stage] = &[
Stage {
name: "euler",
description: "Dependency Analysis",
focus: "dependency graph and hotspots",
},
Stage {
name: "breaker",
description: "Edge Case Discovery",
focus: "whitebox exploration and failure cases",
},
Stage {
name: "hopper",
description: "Testing & Verification",
focus: "deep testing and regression integrity",
},
Stage {
name: "fowler",
description: "Refactoring",
focus: "deduplication and complexity reduction",
},
Stage {
name: "carmack",
description: "Code Polish",
focus: "readability, modularity and concision",
},
Stage {
name: "lamport",
description: "Documentation",
focus: "human-readable docs and validation",
},
Stage {
name: "huffman",
description: "Memory Compression",
focus: "semantic compression to preserve signal",
},
];
/// Status of a single stage execution
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum StageStatus {
/// Not yet started
Pending,
/// Currently running
Running,
/// Completed successfully
Complete {
duration_secs: u64,
commits_processed: u32,
},
/// Failed with error
Failed {
error: String,
attempts: u32,
},
/// Skipped (e.g., no new commits)
Skipped { reason: String },
}
/// State of a single stage in the pipeline
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageState {
/// Agent name
pub name: String,
/// Current status
pub status: StageStatus,
/// When this stage started (if running or complete)
pub started_at: Option<DateTime<Utc>>,
/// When this stage completed (if complete)
pub completed_at: Option<DateTime<Utc>>,
/// Commit hash when this stage last ran
pub last_commit: Option<String>,
}
impl StageState {
/// Create a new pending stage state
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
status: StageStatus::Pending,
started_at: None,
completed_at: None,
last_commit: None,
}
}
}
/// The full pipeline state, persisted to disk
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineState {
/// Unique run identifier
pub run_id: String,
/// When this pipeline run started
pub started_at: DateTime<Utc>,
/// When this pipeline run completed (if complete)
pub completed_at: Option<DateTime<Utc>>,
/// Current stage index (0-based)
pub current_stage: usize,
/// State of each stage
pub stages: Vec<StageState>,
/// The commit cursor - commits up to this hash have been processed
pub commit_cursor: Option<String>,
/// Number of commits to process per run
pub commits_per_run: u32,
/// Git worktree session ID (for crash recovery)
pub session_id: Option<String>,
}
impl PipelineState {
/// Create a new pipeline state
pub fn new(commits_per_run: u32) -> Self {
let run_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
let stages = PIPELINE_STAGES
.iter()
.map(|s| StageState::new(s.name))
.collect();
Self {
run_id,
started_at: Utc::now(),
completed_at: None,
current_stage: 0,
stages,
commit_cursor: None,
commits_per_run,
session_id: None,
}
}
/// Get the path to the pipeline state file
pub fn state_path(repo_root: &Path) -> PathBuf {
repo_root.join("analysis").join("sdlc").join("pipeline.json")
}
/// Get the path to the SDLC directory
pub fn sdlc_dir(repo_root: &Path) -> PathBuf {
repo_root.join("analysis").join("sdlc")
}
/// Load pipeline state from disk, or return None if not found
pub fn load(repo_root: &Path) -> Result<Option<Self>> {
let path = Self::state_path(repo_root);
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)
.context("Failed to read pipeline state")?;
// Handle corrupted state gracefully
match serde_json::from_str(&content) {
Ok(state) => Ok(Some(state)),
Err(e) => {
eprintln!("⚠️ Pipeline state corrupted, starting fresh: {}", e);
Ok(None)
}
}
}
/// Save pipeline state to disk
pub fn save(&self, repo_root: &Path) -> Result<()> {
let dir = Self::sdlc_dir(repo_root);
fs::create_dir_all(&dir)
.context("Failed to create analysis/sdlc directory")?;
let path = Self::state_path(repo_root);
let json = serde_json::to_string_pretty(self)
.context("Failed to serialize pipeline state")?;
fs::write(&path, json)
.context("Failed to write pipeline state")?;
Ok(())
}
/// Delete pipeline state from disk
pub fn delete(repo_root: &Path) -> Result<()> {
let path = Self::state_path(repo_root);
if path.exists() {
fs::remove_file(&path)
.context("Failed to delete pipeline state")?;
}
Ok(())
}
/// Check if the pipeline is complete
pub fn is_complete(&self) -> bool {
self.stages.iter().all(|s| {
matches!(
s.status,
StageStatus::Complete { .. } | StageStatus::Skipped { .. }
)
})
}
/// Check if the pipeline has any failures
pub fn has_failures(&self) -> bool {
self.stages.iter().any(|s| matches!(s.status, StageStatus::Failed { .. }))
}
/// Get the current stage definition
pub fn current_stage_def(&self) -> Option<&'static Stage> {
PIPELINE_STAGES.get(self.current_stage)
}
/// Mark the current stage as running
pub fn mark_running(&mut self) {
if let Some(stage) = self.stages.get_mut(self.current_stage) {
stage.status = StageStatus::Running;
stage.started_at = Some(Utc::now());
}
}
/// Mark the current stage as complete and advance
pub fn mark_complete(&mut self, duration_secs: u64, commits_processed: u32, commit_hash: &str) {
if let Some(stage) = self.stages.get_mut(self.current_stage) {
stage.status = StageStatus::Complete {
duration_secs,
commits_processed,
};
stage.completed_at = Some(Utc::now());
stage.last_commit = Some(commit_hash.to_string());
}
// Advance to next stage
if self.current_stage < PIPELINE_STAGES.len() - 1 {
self.current_stage += 1;
} else {
// Pipeline complete
self.completed_at = Some(Utc::now());
}
// Update cursor
self.commit_cursor = Some(commit_hash.to_string());
}
/// Mark the current stage as failed
pub fn mark_failed(&mut self, error: &str) {
if let Some(stage) = self.stages.get_mut(self.current_stage) {
let attempts = match &stage.status {
StageStatus::Failed { attempts, .. } => attempts + 1,
_ => 1,
};
stage.status = StageStatus::Failed {
error: error.to_string(),
attempts,
};
}
}
/// Mark the current stage as skipped
#[allow(dead_code)]
pub fn mark_skipped(&mut self, reason: &str) {
if let Some(stage) = self.stages.get_mut(self.current_stage) {
stage.status = StageStatus::Skipped {
reason: reason.to_string(),
};
stage.completed_at = Some(Utc::now());
}
// Advance to next stage
if self.current_stage < PIPELINE_STAGES.len() - 1 {
self.current_stage += 1;
} else {
self.completed_at = Some(Utc::now());
}
}
/// Retry the current failed stage
#[allow(dead_code)]
pub fn retry_stage(&mut self) -> Result<()> {
if let Some(stage) = self.stages.get_mut(self.current_stage) {
match &stage.status {
StageStatus::Failed { .. } => {
// Keep the attempt count but reset to pending
stage.status = StageStatus::Pending;
stage.started_at = None;
Ok(())
}
_ => bail!("Stage '{}' is not in failed state", stage.name),
}
} else {
bail!("Invalid current stage index")
}
}
/// Find the first incomplete stage (for resumption)
pub fn find_resume_point(&self) -> usize {
for (i, stage) in self.stages.iter().enumerate() {
match &stage.status {
StageStatus::Pending | StageStatus::Running | StageStatus::Failed { .. } => {
return i;
}
_ => continue,
}
}
// All complete
self.stages.len()
}
/// Resume from the first incomplete stage
pub fn resume(&mut self) {
self.current_stage = self.find_resume_point();
// If current stage was running (crashed), reset to pending
if let Some(stage) = self.stages.get_mut(self.current_stage) {
if matches!(stage.status, StageStatus::Running) {
stage.status = StageStatus::Pending;
stage.started_at = None;
}
}
}
}
/// Get a stage by name
#[allow(dead_code)]
pub fn get_stage(name: &str) -> Option<&'static Stage> {
PIPELINE_STAGES.iter().find(|s| s.name == name)
}
/// Get stage index by name
#[allow(dead_code)]
pub fn get_stage_index(name: &str) -> Option<usize> {
PIPELINE_STAGES.iter().position(|s| s.name == name)
}
/// Display the pipeline with current stage highlighted
pub fn display_pipeline(state: &PipelineState) {
println!();
println!("\x1b[1m┌─────────────────────────────────────────────────────────────┐\x1b[0m");
println!("\x1b[1m│ SDLC Pipeline │\x1b[0m");
println!("\x1b[1m├─────────────────────────────────────────────────────────────┤\x1b[0m");
for (i, stage_def) in PIPELINE_STAGES.iter().enumerate() {
let stage_state = &state.stages[i];
let is_current = i == state.current_stage;
let (icon, color) = match &stage_state.status {
StageStatus::Pending => ("", "\x1b[90m"), // Gray
StageStatus::Running => ("", "\x1b[33m"), // Yellow
StageStatus::Complete { .. } => ("", "\x1b[32m"), // Green
StageStatus::Failed { .. } => ("", "\x1b[31m"), // Red
StageStatus::Skipped { .. } => ("", "\x1b[90m"), // Gray
};
let highlight = if is_current { "\x1b[1m" } else { "" };
let reset = "\x1b[0m";
// Pad to fixed width
let padded = format!("{:<57}", format!("{} {:<10} - {}", icon, stage_def.name, stage_def.description));
if is_current {
println!("{}{}{}{}{}", color, highlight, reset, padded, reset);
} else {
println!("{}{}{} {}", color, highlight, padded, reset);
}
}
println!("\x1b[1m└─────────────────────────────────────────────────────────────┘\x1b[0m");
println!();
}
/// Display a compact single-line status for the current stage
pub fn display_current_stage(state: &PipelineState) {
if let Some(stage) = state.current_stage_def() {
println!(
"\x1b[1;32msdlc:\x1b[0m stage {}/{} \x1b[1m{}\x1b[0m - {}",
state.current_stage + 1,
PIPELINE_STAGES.len(),
stage.name,
stage.focus
);
}
}
/// Generate a summary of the pipeline run
pub fn generate_summary(state: &PipelineState) -> String {
let mut summary = String::new();
summary.push_str("\n## SDLC Pipeline Summary\n\n");
summary.push_str(&format!("**Run ID:** {}\n", state.run_id));
summary.push_str(&format!("**Started:** {}\n", state.started_at.format("%Y-%m-%d %H:%M:%S UTC")));
if let Some(completed) = state.completed_at {
summary.push_str(&format!("**Completed:** {}\n", completed.format("%Y-%m-%d %H:%M:%S UTC")));
let duration = completed.signed_duration_since(state.started_at);
summary.push_str(&format!("**Total Duration:** {}\n", format_duration(duration.num_seconds() as u64)));
}
summary.push_str("\n### Stage Results\n\n");
summary.push_str("| Stage | Status | Duration | Commits |\n");
summary.push_str("|-------|--------|----------|---------|\n");
let mut total_commits = 0u32;
let mut completed_count = 0;
let mut failed_count = 0;
let mut skipped_count = 0;
for (i, stage_def) in PIPELINE_STAGES.iter().enumerate() {
let stage_state = &state.stages[i];
let (status_str, duration_str, commits_str) = match &stage_state.status {
StageStatus::Pending => ("⏳ Pending".to_string(), "-".to_string(), "-".to_string()),
StageStatus::Running => ("🔄 Running".to_string(), "-".to_string(), "-".to_string()),
StageStatus::Complete { duration_secs, commits_processed } => {
completed_count += 1;
total_commits += commits_processed;
(
"✅ Complete".to_string(),
format_duration(*duration_secs),
commits_processed.to_string(),
)
}
StageStatus::Failed { error: _, attempts } => {
failed_count += 1;
(
format!("❌ Failed ({}x)", attempts),
"-".to_string(),
"-".to_string(),
)
}
StageStatus::Skipped { reason: _ } => {
skipped_count += 1;
(
format!("⊘ Skipped"),
"-".to_string(),
"-".to_string(),
)
}
};
summary.push_str(&format!(
"| {} | {} | {} | {} |\n",
stage_def.name, status_str, duration_str, commits_str
));
}
summary.push_str("\n### Summary\n\n");
summary.push_str(&format!("- **Completed:** {} stages\n", completed_count));
summary.push_str(&format!("- **Failed:** {} stages\n", failed_count));
summary.push_str(&format!("- **Skipped:** {} stages\n", skipped_count));
summary.push_str(&format!("- **Total Commits Processed:** {}\n", total_commits));
summary
}
/// Format seconds as human-readable duration
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_pipeline_stages_order() {
let names: Vec<_> = PIPELINE_STAGES.iter().map(|s| s.name).collect();
assert_eq!(
names,
vec!["euler", "breaker", "hopper", "fowler", "carmack", "lamport", "huffman"]
);
}
#[test]
fn test_pipeline_state_new() {
let state = PipelineState::new(10);
assert_eq!(state.stages.len(), 7);
assert_eq!(state.current_stage, 0);
assert_eq!(state.commits_per_run, 10);
assert!(state.stages.iter().all(|s| s.status == StageStatus::Pending));
}
#[test]
fn test_pipeline_state_save_load() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
let state = PipelineState::new(10);
state.save(repo_root).unwrap();
let loaded = PipelineState::load(repo_root).unwrap().unwrap();
assert_eq!(loaded.run_id, state.run_id);
assert_eq!(loaded.stages.len(), 7);
}
#[test]
fn test_pipeline_state_missing_returns_none() {
let temp_dir = TempDir::new().unwrap();
let result = PipelineState::load(temp_dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_pipeline_state_corrupted_returns_none() {
let temp_dir = TempDir::new().unwrap();
let repo_root = temp_dir.path();
// Create corrupted state file
let dir = PipelineState::sdlc_dir(repo_root);
fs::create_dir_all(&dir).unwrap();
fs::write(PipelineState::state_path(repo_root), "not valid json").unwrap();
let result = PipelineState::load(repo_root).unwrap();
assert!(result.is_none());
}
#[test]
fn test_mark_complete_advances_stage() {
let mut state = PipelineState::new(10);
assert_eq!(state.current_stage, 0);
state.mark_running();
state.mark_complete(60, 5, "abc123");
assert_eq!(state.current_stage, 1);
assert!(matches!(state.stages[0].status, StageStatus::Complete { .. }));
assert_eq!(state.commit_cursor, Some("abc123".to_string()));
}
#[test]
fn test_mark_failed_tracks_attempts() {
let mut state = PipelineState::new(10);
state.mark_failed("error 1");
if let StageStatus::Failed { attempts, .. } = &state.stages[0].status {
assert_eq!(*attempts, 1);
} else {
panic!("Expected Failed status");
}
state.mark_failed("error 2");
if let StageStatus::Failed { attempts, .. } = &state.stages[0].status {
assert_eq!(*attempts, 2);
} else {
panic!("Expected Failed status");
}
}
#[test]
fn test_retry_stage() {
let mut state = PipelineState::new(10);
state.mark_failed("some error");
state.retry_stage().unwrap();
assert_eq!(state.stages[0].status, StageStatus::Pending);
}
#[test]
fn test_retry_non_failed_stage_errors() {
let mut state = PipelineState::new(10);
let result = state.retry_stage();
assert!(result.is_err());
}
#[test]
fn test_find_resume_point() {
let mut state = PipelineState::new(10);
// Complete first two stages
state.mark_running();
state.mark_complete(60, 5, "abc");
state.mark_running();
state.mark_complete(60, 5, "def");
// Fail the third
state.mark_failed("error");
assert_eq!(state.find_resume_point(), 2);
}
#[test]
fn test_resume_from_running_state() {
let mut state = PipelineState::new(10);
state.mark_running();
// Simulate crash - stage is still "running"
state.resume();
assert_eq!(state.current_stage, 0);
assert_eq!(state.stages[0].status, StageStatus::Pending);
}
#[test]
fn test_is_complete() {
let mut state = PipelineState::new(10);
assert!(!state.is_complete());
// Complete all stages
for _ in 0..7 {
state.mark_running();
state.mark_complete(60, 5, "abc");
}
assert!(state.is_complete());
}
#[test]
fn test_get_stage() {
assert!(get_stage("euler").is_some());
assert!(get_stage("unknown").is_none());
}
#[test]
fn test_get_stage_index() {
assert_eq!(get_stage_index("euler"), Some(0));
assert_eq!(get_stage_index("breaker"), Some(1));
assert_eq!(get_stage_index("huffman"), Some(6));
assert_eq!(get_stage_index("unknown"), None);
}
}