mod handle;
mod llm;
mod phase_exec;
mod workflow;
#[allow(unused_imports)]
pub use self::handle::OrchestratorHandle;
#[allow(unused_imports)]
pub use self::phase_exec::ExecutionResult;
#[allow(unused_imports)]
pub(crate) use self::workflow::{PhaseExecution, PhaseExecutionResult, WorkflowResult};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::time::Duration;
use crate::config::Selectors;
use crate::error::{PhaseError, XCheckerError};
use crate::hooks::HooksConfig;
use crate::receipt::ReceiptManager;
use crate::status::artifact::ArtifactManager;
use crate::types::PhaseId;
use std::sync::Arc;
pub struct PhaseOrchestrator {
spec_id: String,
artifact_manager: ArtifactManager,
receipt_manager: ReceiptManager,
}
#[derive(Debug, Clone, Default)]
pub struct OrchestratorConfig {
pub dry_run: bool,
pub config: HashMap<String, String>,
pub full_config: Option<crate::config::Config>,
pub selectors: Option<Selectors>,
pub strict_validation: bool,
pub redactor: Arc<crate::redaction::SecretRedactor>,
pub hooks: Option<HooksConfig>,
}
#[derive(Debug, Clone)]
pub struct PhaseTimeout {
pub duration: Duration,
}
impl PhaseTimeout {
pub const DEFAULT_SECS: u64 = 600;
pub const MIN_SECS: u64 = 5;
#[must_use]
pub fn from_secs(secs: u64) -> Self {
let timeout_secs = secs.max(Self::MIN_SECS);
Self {
duration: Duration::from_secs(timeout_secs),
}
}
#[must_use]
pub fn from_config(config: &OrchestratorConfig) -> Self {
let timeout_secs = config
.config
.get("phase_timeout")
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(Self::DEFAULT_SECS);
Self::from_secs(timeout_secs)
}
}
impl PhaseOrchestrator {
pub fn new(spec_id: &str) -> Result<Self> {
Self::new_with_force(spec_id, false)
}
pub fn new_with_force(spec_id: &str, force: bool) -> Result<Self> {
let artifact_manager = ArtifactManager::new_with_force(spec_id, force)
.with_context(|| format!("Failed to create artifact manager for spec: {spec_id}"))?;
let receipt_manager = ReceiptManager::new(artifact_manager.base_path());
Ok(Self {
spec_id: spec_id.to_string(),
artifact_manager,
receipt_manager,
})
}
pub fn new_readonly(spec_id: &str) -> Result<Self> {
let base_path = crate::paths::spec_root(spec_id);
let artifact_manager = ArtifactManager::new_readonly(spec_id)?;
let receipt_manager = ReceiptManager::new(&base_path);
Ok(Self {
spec_id: spec_id.to_string(),
artifact_manager,
receipt_manager,
})
}
fn can_resume_from_phase(&self, phase_id: PhaseId) -> Result<bool> {
let deps = match phase_id {
PhaseId::Requirements => &[][..],
PhaseId::Design => &[PhaseId::Requirements][..],
PhaseId::Tasks => &[PhaseId::Design][..],
PhaseId::Review => &[PhaseId::Tasks][..],
PhaseId::Fixup => &[PhaseId::Review][..],
PhaseId::Final => &[PhaseId::Tasks][..], };
for dep_phase in deps {
if !self.artifact_manager.phase_completed(*dep_phase) {
return Ok(false);
}
if let Some(receipt) = self.receipt_manager.read_latest_receipt(*dep_phase)? {
if receipt.exit_code != 0 {
return Ok(false);
}
} else {
return Ok(false);
}
}
Ok(true)
}
#[doc(hidden)]
pub fn validate_transition(&self, target_phase: PhaseId) -> Result<(), XCheckerError> {
let current_phase = self.get_current_phase().map_err(|e| {
XCheckerError::Phase(PhaseError::ContextCreationFailed {
phase: target_phase.as_str().to_string(),
reason: format!("Failed to determine current phase: {e}"),
})
})?;
let legal_next_phases = match current_phase {
None => vec![PhaseId::Requirements], Some(PhaseId::Requirements) => vec![PhaseId::Requirements, PhaseId::Design],
Some(PhaseId::Design) => vec![PhaseId::Design, PhaseId::Tasks],
Some(PhaseId::Tasks) => vec![PhaseId::Tasks, PhaseId::Review, PhaseId::Final],
Some(PhaseId::Review) => vec![PhaseId::Review, PhaseId::Fixup, PhaseId::Final],
Some(PhaseId::Fixup) => vec![PhaseId::Fixup, PhaseId::Final],
Some(PhaseId::Final) => vec![PhaseId::Final], };
if !legal_next_phases.contains(&target_phase) {
let current_str = current_phase.map_or_else(
|| "none (fresh spec)".to_string(),
|p| p.as_str().to_string(),
);
return Err(XCheckerError::Phase(PhaseError::InvalidTransition {
from: current_str,
to: target_phase.as_str().to_string(),
}));
}
self.check_dependencies_satisfied(target_phase)?;
Ok(())
}
fn get_current_phase(&self) -> Result<Option<PhaseId>> {
let phases = [
PhaseId::Final,
PhaseId::Fixup,
PhaseId::Review,
PhaseId::Tasks,
PhaseId::Design,
PhaseId::Requirements,
];
for phase in &phases {
if let Some(receipt) = self.receipt_manager.read_latest_receipt(*phase)?
&& receipt.exit_code == 0
{
return Ok(Some(*phase));
}
}
Ok(None) }
fn check_dependencies_satisfied(&self, phase_id: PhaseId) -> Result<(), XCheckerError> {
let deps = match phase_id {
PhaseId::Requirements => &[][..],
PhaseId::Design => &[PhaseId::Requirements][..],
PhaseId::Tasks => &[PhaseId::Design][..],
PhaseId::Review => &[PhaseId::Tasks][..],
PhaseId::Fixup => &[PhaseId::Review][..],
PhaseId::Final => &[PhaseId::Tasks][..], };
for dep_phase in deps {
let receipt_result = self
.receipt_manager
.read_latest_receipt(*dep_phase)
.map_err(|e| {
XCheckerError::Phase(PhaseError::ContextCreationFailed {
phase: phase_id.as_str().to_string(),
reason: format!(
"Failed to read receipt for dependency {}: {}",
dep_phase.as_str(),
e
),
})
})?;
if let Some(receipt) = receipt_result {
if receipt.exit_code != 0 {
return Err(XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
phase: phase_id.as_str().to_string(),
dependency: dep_phase.as_str().to_string(),
}));
}
} else {
return Err(XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
phase: phase_id.as_str().to_string(),
dependency: dep_phase.as_str().to_string(),
}));
}
}
Ok(())
}
#[must_use]
pub(crate) fn spec_id(&self) -> &str {
&self.spec_id
}
#[must_use]
pub fn artifact_manager(&self) -> &ArtifactManager {
&self.artifact_manager
}
#[must_use]
pub fn receipt_manager(&self) -> &ReceiptManager {
&self.receipt_manager
}
#[doc(hidden)]
#[allow(dead_code)]
pub fn get_current_phase_state(&self) -> Result<Option<PhaseId>> {
self.get_current_phase()
}
#[doc(hidden)]
#[allow(dead_code)]
pub fn can_resume_from_phase_public(&self, phase_id: PhaseId) -> Result<bool> {
self.can_resume_from_phase(phase_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::phase::{NextStep, Phase, PhaseContext};
use crate::phases::RequirementsPhase;
use crate::test_support;
use std::env;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard, OnceLock};
use tempfile::TempDir;
static ORCHESTRATOR_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn orchestrator_env_guard() -> MutexGuard<'static, ()> {
ORCHESTRATOR_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap()
}
#[allow(dead_code)] fn setup_test_environment() -> (PhaseOrchestrator, TempDir) {
let _lock = orchestrator_env_guard();
let temp_dir = TempDir::new().unwrap();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(temp_dir.path()).unwrap();
let orchestrator = PhaseOrchestrator::new("test-spec-123").unwrap();
env::set_current_dir(original_dir).unwrap();
(orchestrator, temp_dir)
}
#[allow(dead_code)] fn setup_test_environment_with_cleanup() -> (PhaseOrchestrator, TempDir) {
let _lock = orchestrator_env_guard();
let temp_dir = TempDir::new().unwrap();
env::set_current_dir(temp_dir.path()).unwrap();
let orchestrator = PhaseOrchestrator::new("test-spec-123").unwrap();
(orchestrator, temp_dir)
}
#[allow(dead_code)] fn setup_test_with_unique_id(test_name: &str) -> (PhaseOrchestrator, TempDir) {
let _lock = orchestrator_env_guard();
let temp_dir = TempDir::new().unwrap();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(temp_dir.path()).unwrap();
let spec_id = format!("test-{test_name}");
let orchestrator = match PhaseOrchestrator::new(&spec_id) {
Ok(orch) => orch,
Err(e) => {
env::set_current_dir(original_dir).unwrap();
panic!("Failed to create orchestrator: {e}");
}
};
(orchestrator, temp_dir)
}
struct TempDirGuard {
_lock: MutexGuard<'static, ()>,
_temp_dir: TempDir,
_home_dir: TempDir,
original_dir: PathBuf,
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = env::set_current_dir(&self.original_dir);
}
}
fn setup_test_with_guard(test_name: &str) -> (PhaseOrchestrator, TempDirGuard) {
let lock = orchestrator_env_guard();
let home_dir = crate::paths::with_isolated_home();
let temp_dir = TempDir::new().unwrap();
let original_dir = env::current_dir().unwrap();
env::set_current_dir(temp_dir.path()).unwrap();
let spec_id = format!("test-{}-{}", test_name, std::process::id());
let orchestrator = PhaseOrchestrator::new(&spec_id).unwrap();
let guard = TempDirGuard {
_lock: lock,
_temp_dir: temp_dir,
_home_dir: home_dir,
original_dir,
};
(orchestrator, guard)
}
#[test]
fn test_orchestrator_creation() {
let config = OrchestratorConfig::default();
assert!(!config.dry_run);
assert!(config.config.is_empty());
}
#[tokio::test]
async fn test_requirements_phase_execution() {
let (orchestrator, _guard) = setup_test_with_guard("execution");
let config = OrchestratorConfig {
dry_run: true,
config: HashMap::new(),
full_config: None,
selectors: None,
strict_validation: false,
redactor: std::sync::Arc::new(crate::redaction::SecretRedactor::default()),
hooks: None,
};
let result = orchestrator.execute_requirements_phase(&config).await;
if let Err(ref e) = result {
eprintln!("Test failed with error: {e:?}");
}
assert!(result.is_ok());
let execution_result = result.unwrap();
assert_eq!(execution_result.phase, PhaseId::Requirements);
assert!(execution_result.success);
assert_eq!(execution_result.exit_code, 0);
assert!(!execution_result.artifact_paths.is_empty());
assert!(execution_result.receipt_path.is_some());
assert!(execution_result.error.is_none());
}
#[test]
fn test_phase_context_creation() {
use std::path::PathBuf;
let context = PhaseContext {
spec_id: "test-spec".to_string(),
spec_dir: PathBuf::from("/tmp/test"),
config: HashMap::new(),
artifacts: vec!["test-artifact.md".to_string()],
selectors: None,
strict_validation: false,
redactor: std::sync::Arc::new(crate::redaction::SecretRedactor::default()),
};
assert_eq!(context.spec_id, "test-spec");
assert_eq!(context.artifacts.len(), 1);
assert_eq!(context.artifacts[0], "test-artifact.md");
}
#[test]
fn test_dependency_checking() {
let requirements_phase = RequirementsPhase::new();
let design_phase = crate::phases::DesignPhase::new();
assert_eq!(requirements_phase.deps().len(), 0);
assert_eq!(design_phase.deps().len(), 1);
assert_eq!(design_phase.deps()[0], PhaseId::Requirements);
}
#[test]
fn test_claude_response_simulation() {
let spec_id = "test-claude";
let response = format!(
r"# Requirements Document
## Introduction
This is a generated requirements document for spec {}. The system will provide core functionality for managing and processing specifications through a structured workflow.
## Requirements
### Requirement 1
**User Story:** As a developer, I want to generate structured requirements from rough ideas, so that I can create comprehensive specifications efficiently.
#### Acceptance Criteria
1. WHEN I provide a problem statement THEN the system SHALL generate structured requirements in EARS format
2. WHEN requirements are generated THEN they SHALL include user stories and acceptance criteria
3. WHEN the process completes THEN the system SHALL produce both markdown and YAML artifacts
",
spec_id
);
assert!(!response.is_empty());
assert!(response.contains("Requirements Document"));
assert!(response.contains("test-claude"));
assert!(response.contains("User Story:"));
assert!(response.contains("Acceptance Criteria"));
}
#[test]
fn test_execution_result_structure() {
let result = ExecutionResult {
phase: PhaseId::Requirements,
success: true,
exit_code: 0,
artifact_paths: vec![],
receipt_path: None,
error: None,
};
assert_eq!(result.phase, PhaseId::Requirements);
assert!(result.success);
assert_eq!(result.exit_code, 0);
assert!(result.artifact_paths.is_empty());
assert!(result.receipt_path.is_none());
assert!(result.error.is_none());
}
#[tokio::test]
async fn test_secret_scanning_before_claude_invocation() {
let (orchestrator, _guard) = setup_test_with_guard("secret-scan");
struct SecretPhase;
impl Phase for SecretPhase {
fn id(&self) -> PhaseId {
PhaseId::Requirements
}
fn deps(&self) -> &'static [PhaseId] {
&[]
}
fn can_resume(&self) -> bool {
true
}
fn prompt(&self, _ctx: &PhaseContext) -> String {
"Generate requirements".to_string()
}
fn make_packet(&self, _ctx: &PhaseContext) -> Result<xchecker_packet::Packet> {
let token = test_support::github_pat();
let content = format!("Here is my GitHub token: {}\nSome other content", token);
let blake3_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
let evidence = crate::types::PacketEvidence {
files: vec![],
max_bytes: 65536,
max_lines: 1200,
};
let mut budget = xchecker_packet::BudgetUsage::new(65536, 1200);
budget.add_content(content.len(), content.lines().count());
Ok(xchecker_packet::Packet::new(
content,
blake3_hash,
evidence,
budget,
))
}
fn postprocess(
&self,
_raw: &str,
_ctx: &PhaseContext,
) -> Result<xchecker_phase_api::PhaseResult> {
unreachable!("Should not reach postprocess when secret is detected");
}
}
let phase = SecretPhase;
let config = OrchestratorConfig::default();
let result = orchestrator.execute_phase(&phase, &config).await;
assert!(result.is_ok(), "Should return Ok with error result");
let exec_result = result.unwrap();
assert!(!exec_result.success, "Execution should fail");
assert_eq!(
exec_result.exit_code,
crate::exit_codes::codes::SECRET_DETECTED
);
assert!(exec_result.error.is_some(), "Should have error message");
assert!(exec_result.error.unwrap().contains("Secret detected"));
assert!(
exec_result.receipt_path.is_some(),
"Receipt should be written"
);
}
#[tokio::test]
async fn test_packet_evidence_populated_in_receipt() {
let (orchestrator, _guard) = setup_test_with_guard("packet-evidence");
struct EvidencePhase;
impl Phase for EvidencePhase {
fn id(&self) -> PhaseId {
PhaseId::Requirements
}
fn deps(&self) -> &'static [PhaseId] {
&[]
}
fn can_resume(&self) -> bool {
true
}
fn prompt(&self, _ctx: &PhaseContext) -> String {
"Generate requirements".to_string()
}
fn make_packet(&self, _ctx: &PhaseContext) -> Result<xchecker_packet::Packet> {
let content = "Test packet content without secrets";
let blake3_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
let evidence = crate::types::PacketEvidence {
files: vec![
crate::types::FileEvidence {
path: "src/main.rs".to_string(),
range: Some("L1-L100".to_string()),
blake3_pre_redaction: "abc123".to_string(),
priority: crate::types::Priority::High,
},
crate::types::FileEvidence {
path: "Cargo.toml".to_string(),
range: Some("L1-L50".to_string()),
blake3_pre_redaction: "def456".to_string(),
priority: crate::types::Priority::Medium,
},
],
max_bytes: 65536,
max_lines: 1200,
};
let mut budget = xchecker_packet::BudgetUsage::new(65536, 1200);
budget.add_content(content.len(), content.lines().count());
Ok(xchecker_packet::Packet::new(
content.to_string(),
blake3_hash,
evidence,
budget,
))
}
fn postprocess(
&self,
_raw: &str,
ctx: &PhaseContext,
) -> Result<crate::phase::PhaseResult> {
let artifact = crate::status::artifact::Artifact {
name: "00-requirements.md".to_string(),
content: format!("# Requirements for {}\n\nTest requirements.", ctx.spec_id),
artifact_type: crate::status::artifact::ArtifactType::Markdown,
blake3_hash: String::new(), };
Ok(crate::phase::PhaseResult {
artifacts: vec![artifact],
next_step: NextStep::Continue,
metadata: crate::phase::PhaseMetadata {
packet_hash: None,
budget_used: None,
duration_ms: None,
},
})
}
}
let phase = EvidencePhase;
let config = OrchestratorConfig {
dry_run: true,
config: HashMap::new(),
full_config: None,
selectors: None,
strict_validation: false,
redactor: std::sync::Arc::new(crate::redaction::SecretRedactor::default()),
hooks: None,
};
let result = orchestrator.execute_phase(&phase, &config).await;
assert!(result.is_ok(), "Phase execution should succeed");
let exec_result = result.unwrap();
assert!(exec_result.success, "Execution should succeed");
let receipt_path = exec_result.receipt_path.expect("Receipt path should exist");
let receipt_content = std::fs::read_to_string(&receipt_path).expect("Should read receipt");
let receipt: serde_json::Value =
serde_json::from_str(&receipt_content).expect("Should parse receipt");
let packet = &receipt["packet"];
assert!(packet.is_object(), "packet field should exist");
let files = &packet["files"];
assert!(files.is_array(), "files should be an array");
assert_eq!(files.as_array().unwrap().len(), 2, "Should have 2 files");
let first_file = &files[0];
assert_eq!(first_file["path"], "src/main.rs");
assert_eq!(first_file["range"], "L1-L100");
assert_eq!(first_file["blake3_pre_redaction"], "abc123");
let second_file = &files[1];
assert_eq!(second_file["path"], "Cargo.toml");
assert_eq!(second_file["range"], "L1-L50");
assert_eq!(second_file["blake3_pre_redaction"], "def456");
}
}