1mod handle;
8mod llm;
9mod phase_exec;
10mod workflow;
11
12#[allow(unused_imports)]
13pub use self::handle::OrchestratorHandle;
14
15#[allow(unused_imports)]
16pub use self::phase_exec::ExecutionResult;
17
18#[allow(unused_imports)]
20pub(crate) use self::workflow::{PhaseExecution, PhaseExecutionResult, WorkflowResult};
21
22use anyhow::{Context, Result};
23use std::collections::HashMap;
24use std::time::Duration;
25
26use crate::config::Selectors;
27use crate::error::{PhaseError, XCheckerError};
28use crate::hooks::HooksConfig;
29use crate::receipt::ReceiptManager;
30use crate::status::artifact::ArtifactManager;
31use crate::types::PhaseId;
32use std::sync::Arc;
33
34pub struct PhaseOrchestrator {
71 spec_id: String,
72 artifact_manager: ArtifactManager,
73 receipt_manager: ReceiptManager,
74}
75
76#[derive(Debug, Clone, Default)]
86pub struct OrchestratorConfig {
87 pub dry_run: bool,
89 pub config: HashMap<String, String>,
91 pub full_config: Option<crate::config::Config>,
96 pub selectors: Option<Selectors>,
101 pub strict_validation: bool,
107 pub redactor: Arc<crate::redaction::SecretRedactor>,
111 pub hooks: Option<HooksConfig>,
113}
114
115#[derive(Debug, Clone)]
120pub struct PhaseTimeout {
121 pub duration: Duration,
123}
124
125impl PhaseTimeout {
126 pub const DEFAULT_SECS: u64 = 600;
128
129 pub const MIN_SECS: u64 = 5;
131
132 #[must_use]
134 pub fn from_secs(secs: u64) -> Self {
135 let timeout_secs = secs.max(Self::MIN_SECS);
136 Self {
137 duration: Duration::from_secs(timeout_secs),
138 }
139 }
140
141 #[must_use]
144 pub fn from_config(config: &OrchestratorConfig) -> Self {
145 let timeout_secs = config
146 .config
147 .get("phase_timeout")
148 .and_then(|s| s.parse::<u64>().ok())
149 .unwrap_or(Self::DEFAULT_SECS);
150
151 Self::from_secs(timeout_secs)
152 }
153}
154
155impl PhaseOrchestrator {
156 pub fn new(spec_id: &str) -> Result<Self> {
163 Self::new_with_force(spec_id, false)
164 }
165
166 pub fn new_with_force(spec_id: &str, force: bool) -> Result<Self> {
178 let artifact_manager = ArtifactManager::new_with_force(spec_id, force)
179 .with_context(|| format!("Failed to create artifact manager for spec: {spec_id}"))?;
180
181 let receipt_manager = ReceiptManager::new(artifact_manager.base_path());
182
183 Ok(Self {
184 spec_id: spec_id.to_string(),
185 artifact_manager,
186 receipt_manager,
187 })
188 }
189
190 pub fn new_readonly(spec_id: &str) -> Result<Self> {
198 let base_path = crate::paths::spec_root(spec_id);
200
201 let artifact_manager = ArtifactManager::new_readonly(spec_id)?;
203 let receipt_manager = ReceiptManager::new(&base_path);
204
205 Ok(Self {
206 spec_id: spec_id.to_string(),
207 artifact_manager,
208 receipt_manager,
209 })
210 }
211
212 fn can_resume_from_phase(&self, phase_id: PhaseId) -> Result<bool> {
214 let deps = match phase_id {
216 PhaseId::Requirements => &[][..],
217 PhaseId::Design => &[PhaseId::Requirements][..],
218 PhaseId::Tasks => &[PhaseId::Design][..],
219 PhaseId::Review => &[PhaseId::Tasks][..],
220 PhaseId::Fixup => &[PhaseId::Review][..],
221 PhaseId::Final => &[PhaseId::Tasks][..], };
223
224 for dep_phase in deps {
225 if !self.artifact_manager.phase_completed(*dep_phase) {
226 return Ok(false);
227 }
228
229 if let Some(receipt) = self.receipt_manager.read_latest_receipt(*dep_phase)? {
231 if receipt.exit_code != 0 {
232 return Ok(false);
233 }
234 } else {
235 return Ok(false);
236 }
237 }
238
239 Ok(true)
240 }
241
242 #[doc(hidden)]
267 pub fn validate_transition(&self, target_phase: PhaseId) -> Result<(), XCheckerError> {
268 let current_phase = self.get_current_phase().map_err(|e| {
270 XCheckerError::Phase(PhaseError::ContextCreationFailed {
271 phase: target_phase.as_str().to_string(),
272 reason: format!("Failed to determine current phase: {e}"),
273 })
274 })?;
275
276 let legal_next_phases = match current_phase {
278 None => vec![PhaseId::Requirements], Some(PhaseId::Requirements) => vec![PhaseId::Requirements, PhaseId::Design],
280 Some(PhaseId::Design) => vec![PhaseId::Design, PhaseId::Tasks],
281 Some(PhaseId::Tasks) => vec![PhaseId::Tasks, PhaseId::Review, PhaseId::Final],
282 Some(PhaseId::Review) => vec![PhaseId::Review, PhaseId::Fixup, PhaseId::Final],
283 Some(PhaseId::Fixup) => vec![PhaseId::Fixup, PhaseId::Final],
284 Some(PhaseId::Final) => vec![PhaseId::Final], };
286
287 if !legal_next_phases.contains(&target_phase) {
289 let current_str = current_phase.map_or_else(
290 || "none (fresh spec)".to_string(),
291 |p| p.as_str().to_string(),
292 );
293
294 return Err(XCheckerError::Phase(PhaseError::InvalidTransition {
295 from: current_str,
296 to: target_phase.as_str().to_string(),
297 }));
298 }
299
300 self.check_dependencies_satisfied(target_phase)?;
302
303 Ok(())
304 }
305
306 fn get_current_phase(&self) -> Result<Option<PhaseId>> {
308 let phases = [
310 PhaseId::Final,
311 PhaseId::Fixup,
312 PhaseId::Review,
313 PhaseId::Tasks,
314 PhaseId::Design,
315 PhaseId::Requirements,
316 ];
317
318 for phase in &phases {
319 if let Some(receipt) = self.receipt_manager.read_latest_receipt(*phase)?
320 && receipt.exit_code == 0
321 {
322 return Ok(Some(*phase));
323 }
324 }
325
326 Ok(None) }
328
329 fn check_dependencies_satisfied(&self, phase_id: PhaseId) -> Result<(), XCheckerError> {
331 let deps = match phase_id {
332 PhaseId::Requirements => &[][..],
333 PhaseId::Design => &[PhaseId::Requirements][..],
334 PhaseId::Tasks => &[PhaseId::Design][..],
335 PhaseId::Review => &[PhaseId::Tasks][..],
336 PhaseId::Fixup => &[PhaseId::Review][..],
337 PhaseId::Final => &[PhaseId::Tasks][..], };
339
340 for dep_phase in deps {
341 let receipt_result = self
343 .receipt_manager
344 .read_latest_receipt(*dep_phase)
345 .map_err(|e| {
346 XCheckerError::Phase(PhaseError::ContextCreationFailed {
347 phase: phase_id.as_str().to_string(),
348 reason: format!(
349 "Failed to read receipt for dependency {}: {}",
350 dep_phase.as_str(),
351 e
352 ),
353 })
354 })?;
355
356 if let Some(receipt) = receipt_result {
357 if receipt.exit_code != 0 {
358 return Err(XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
359 phase: phase_id.as_str().to_string(),
360 dependency: dep_phase.as_str().to_string(),
361 }));
362 }
363 } else {
364 return Err(XCheckerError::Phase(PhaseError::DependencyNotSatisfied {
365 phase: phase_id.as_str().to_string(),
366 dependency: dep_phase.as_str().to_string(),
367 }));
368 }
369 }
370
371 Ok(())
372 }
373
374 #[must_use]
378 pub(crate) fn spec_id(&self) -> &str {
379 &self.spec_id
380 }
381
382 #[must_use]
384 pub fn artifact_manager(&self) -> &ArtifactManager {
385 &self.artifact_manager
386 }
387
388 #[must_use]
390 pub fn receipt_manager(&self) -> &ReceiptManager {
391 &self.receipt_manager
392 }
393
394 #[doc(hidden)]
408 #[allow(dead_code)]
409 pub fn get_current_phase_state(&self) -> Result<Option<PhaseId>> {
410 self.get_current_phase()
411 }
412
413 #[doc(hidden)]
427 #[allow(dead_code)]
428 pub fn can_resume_from_phase_public(&self, phase_id: PhaseId) -> Result<bool> {
429 self.can_resume_from_phase(phase_id)
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::phase::{NextStep, Phase, PhaseContext};
437 use crate::phases::RequirementsPhase;
438 use crate::test_support;
439 use std::env;
440 use std::path::PathBuf;
441 use std::sync::{Mutex, MutexGuard, OnceLock};
442 use tempfile::TempDir;
443
444 static ORCHESTRATOR_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
447
448 fn orchestrator_env_guard() -> MutexGuard<'static, ()> {
449 ORCHESTRATOR_ENV_LOCK
450 .get_or_init(|| Mutex::new(()))
451 .lock()
452 .unwrap()
453 }
454
455 #[allow(dead_code)] fn setup_test_environment() -> (PhaseOrchestrator, TempDir) {
457 let _lock = orchestrator_env_guard();
458 let temp_dir = TempDir::new().unwrap();
459
460 let original_dir = env::current_dir().unwrap();
462 env::set_current_dir(temp_dir.path()).unwrap();
463
464 let orchestrator = PhaseOrchestrator::new("test-spec-123").unwrap();
465
466 env::set_current_dir(original_dir).unwrap();
468
469 (orchestrator, temp_dir)
470 }
471
472 #[allow(dead_code)] fn setup_test_environment_with_cleanup() -> (PhaseOrchestrator, TempDir) {
474 let _lock = orchestrator_env_guard();
475 let temp_dir = TempDir::new().unwrap();
476
477 env::set_current_dir(temp_dir.path()).unwrap();
479
480 let orchestrator = PhaseOrchestrator::new("test-spec-123").unwrap();
481
482 (orchestrator, temp_dir)
483 }
484
485 #[allow(dead_code)] fn setup_test_with_unique_id(test_name: &str) -> (PhaseOrchestrator, TempDir) {
487 let _lock = orchestrator_env_guard();
488 let temp_dir = TempDir::new().unwrap();
489
490 let original_dir = env::current_dir().unwrap();
492 env::set_current_dir(temp_dir.path()).unwrap();
493
494 let spec_id = format!("test-{test_name}");
495
496 let orchestrator = match PhaseOrchestrator::new(&spec_id) {
498 Ok(orch) => orch,
499 Err(e) => {
500 env::set_current_dir(original_dir).unwrap();
502 panic!("Failed to create orchestrator: {e}");
503 }
504 };
505
506 (orchestrator, temp_dir)
510 }
511
512 struct TempDirGuard {
513 _lock: MutexGuard<'static, ()>,
515 _temp_dir: TempDir,
516 _home_dir: TempDir,
517 original_dir: PathBuf,
518 }
519
520 impl Drop for TempDirGuard {
521 fn drop(&mut self) {
522 let _ = env::set_current_dir(&self.original_dir);
523 }
525 }
526
527 fn setup_test_with_guard(test_name: &str) -> (PhaseOrchestrator, TempDirGuard) {
528 let lock = orchestrator_env_guard();
530
531 let home_dir = crate::paths::with_isolated_home();
533
534 let temp_dir = TempDir::new().unwrap();
535 let original_dir = env::current_dir().unwrap();
536
537 env::set_current_dir(temp_dir.path()).unwrap();
538
539 let spec_id = format!("test-{}-{}", test_name, std::process::id());
541 let orchestrator = PhaseOrchestrator::new(&spec_id).unwrap();
542
543 let guard = TempDirGuard {
544 _lock: lock,
545 _temp_dir: temp_dir,
546 _home_dir: home_dir,
547 original_dir,
548 };
549
550 (orchestrator, guard)
551 }
552
553 #[test]
554 fn test_orchestrator_creation() {
555 let config = OrchestratorConfig::default();
558 assert!(!config.dry_run);
559 assert!(config.config.is_empty());
560 }
561
562 #[tokio::test]
563 async fn test_requirements_phase_execution() {
564 let (orchestrator, _guard) = setup_test_with_guard("execution");
565
566 let config = OrchestratorConfig {
567 dry_run: true,
568 config: HashMap::new(),
569 full_config: None,
570 selectors: None,
571 strict_validation: false,
572 redactor: std::sync::Arc::new(crate::redaction::SecretRedactor::default()),
573 hooks: None,
574 };
575
576 let result = orchestrator.execute_requirements_phase(&config).await;
577 if let Err(ref e) = result {
578 eprintln!("Test failed with error: {e:?}");
579 }
580 assert!(result.is_ok());
581
582 let execution_result = result.unwrap();
583 assert_eq!(execution_result.phase, PhaseId::Requirements);
584 assert!(execution_result.success);
585 assert_eq!(execution_result.exit_code, 0);
586 assert!(!execution_result.artifact_paths.is_empty());
587 assert!(execution_result.receipt_path.is_some());
588 assert!(execution_result.error.is_none());
589 }
590
591 #[test]
592 fn test_phase_context_creation() {
593 use std::path::PathBuf;
595
596 let context = PhaseContext {
597 spec_id: "test-spec".to_string(),
598 spec_dir: PathBuf::from("/tmp/test"),
599 config: HashMap::new(),
600 artifacts: vec!["test-artifact.md".to_string()],
601 selectors: None,
602 strict_validation: false,
603 redactor: std::sync::Arc::new(crate::redaction::SecretRedactor::default()),
604 };
605
606 assert_eq!(context.spec_id, "test-spec");
607 assert_eq!(context.artifacts.len(), 1);
608 assert_eq!(context.artifacts[0], "test-artifact.md");
609 }
610
611 #[test]
612 fn test_dependency_checking() {
613 let requirements_phase = RequirementsPhase::new();
615 let design_phase = crate::phases::DesignPhase::new();
616
617 assert_eq!(requirements_phase.deps().len(), 0);
619
620 assert_eq!(design_phase.deps().len(), 1);
622 assert_eq!(design_phase.deps()[0], PhaseId::Requirements);
623 }
624
625 #[test]
626 fn test_claude_response_simulation() {
627 let spec_id = "test-claude";
632 let response = format!(
633 r"# Requirements Document
634
635## Introduction
636
637This is a generated requirements document for spec {}. The system will provide core functionality for managing and processing specifications through a structured workflow.
638
639## Requirements
640
641### Requirement 1
642
643**User Story:** As a developer, I want to generate structured requirements from rough ideas, so that I can create comprehensive specifications efficiently.
644
645#### Acceptance Criteria
646
6471. WHEN I provide a problem statement THEN the system SHALL generate structured requirements in EARS format
6482. WHEN requirements are generated THEN they SHALL include user stories and acceptance criteria
6493. WHEN the process completes THEN the system SHALL produce both markdown and YAML artifacts
650",
651 spec_id
652 );
653
654 assert!(!response.is_empty());
656 assert!(response.contains("Requirements Document"));
657 assert!(response.contains("test-claude"));
658 assert!(response.contains("User Story:"));
659 assert!(response.contains("Acceptance Criteria"));
660 }
661
662 #[test]
663 fn test_execution_result_structure() {
664 let result = ExecutionResult {
666 phase: PhaseId::Requirements,
667 success: true,
668 exit_code: 0,
669 artifact_paths: vec![],
670 receipt_path: None,
671 error: None,
672 };
673
674 assert_eq!(result.phase, PhaseId::Requirements);
675 assert!(result.success);
676 assert_eq!(result.exit_code, 0);
677 assert!(result.artifact_paths.is_empty());
678 assert!(result.receipt_path.is_none());
679 assert!(result.error.is_none());
680 }
681
682 #[tokio::test]
683 async fn test_secret_scanning_before_claude_invocation() {
684 let (orchestrator, _guard) = setup_test_with_guard("secret-scan");
688
689 struct SecretPhase;
691
692 impl Phase for SecretPhase {
693 fn id(&self) -> PhaseId {
694 PhaseId::Requirements
695 }
696
697 fn deps(&self) -> &'static [PhaseId] {
698 &[]
699 }
700
701 fn can_resume(&self) -> bool {
702 true
703 }
704
705 fn prompt(&self, _ctx: &PhaseContext) -> String {
706 "Generate requirements".to_string()
707 }
708
709 fn make_packet(&self, _ctx: &PhaseContext) -> Result<xchecker_packet::Packet> {
710 let token = test_support::github_pat();
712 let content = format!("Here is my GitHub token: {}\nSome other content", token);
713 let blake3_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
714
715 let evidence = crate::types::PacketEvidence {
716 files: vec![],
717 max_bytes: 65536,
718 max_lines: 1200,
719 };
720
721 let mut budget = xchecker_packet::BudgetUsage::new(65536, 1200);
722 budget.add_content(content.len(), content.lines().count());
723
724 Ok(xchecker_packet::Packet::new(
725 content,
726 blake3_hash,
727 evidence,
728 budget,
729 ))
730 }
731
732 fn postprocess(
733 &self,
734 _raw: &str,
735 _ctx: &PhaseContext,
736 ) -> Result<xchecker_phase_api::PhaseResult> {
737 unreachable!("Should not reach postprocess when secret is detected");
738 }
739 }
740
741 let phase = SecretPhase;
742 let config = OrchestratorConfig::default();
743
744 let result = orchestrator.execute_phase(&phase, &config).await;
746
747 assert!(result.is_ok(), "Should return Ok with error result");
748 let exec_result = result.unwrap();
749
750 assert!(!exec_result.success, "Execution should fail");
752 assert_eq!(
753 exec_result.exit_code,
754 crate::exit_codes::codes::SECRET_DETECTED
755 );
756 assert!(exec_result.error.is_some(), "Should have error message");
757 assert!(exec_result.error.unwrap().contains("Secret detected"));
758
759 assert!(
761 exec_result.receipt_path.is_some(),
762 "Receipt should be written"
763 );
764 }
765
766 #[tokio::test]
767 async fn test_packet_evidence_populated_in_receipt() {
768 let (orchestrator, _guard) = setup_test_with_guard("packet-evidence");
772
773 struct EvidencePhase;
775
776 impl Phase for EvidencePhase {
777 fn id(&self) -> PhaseId {
778 PhaseId::Requirements
779 }
780
781 fn deps(&self) -> &'static [PhaseId] {
782 &[]
783 }
784
785 fn can_resume(&self) -> bool {
786 true
787 }
788
789 fn prompt(&self, _ctx: &PhaseContext) -> String {
790 "Generate requirements".to_string()
791 }
792
793 fn make_packet(&self, _ctx: &PhaseContext) -> Result<xchecker_packet::Packet> {
794 let content = "Test packet content without secrets";
795 let blake3_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
796
797 let evidence = crate::types::PacketEvidence {
799 files: vec![
800 crate::types::FileEvidence {
801 path: "src/main.rs".to_string(),
802 range: Some("L1-L100".to_string()),
803 blake3_pre_redaction: "abc123".to_string(),
804 priority: crate::types::Priority::High,
805 },
806 crate::types::FileEvidence {
807 path: "Cargo.toml".to_string(),
808 range: Some("L1-L50".to_string()),
809 blake3_pre_redaction: "def456".to_string(),
810 priority: crate::types::Priority::Medium,
811 },
812 ],
813 max_bytes: 65536,
814 max_lines: 1200,
815 };
816
817 let mut budget = xchecker_packet::BudgetUsage::new(65536, 1200);
818 budget.add_content(content.len(), content.lines().count());
819
820 Ok(xchecker_packet::Packet::new(
821 content.to_string(),
822 blake3_hash,
823 evidence,
824 budget,
825 ))
826 }
827
828 fn postprocess(
829 &self,
830 _raw: &str,
831 ctx: &PhaseContext,
832 ) -> Result<crate::phase::PhaseResult> {
833 let artifact = crate::status::artifact::Artifact {
835 name: "00-requirements.md".to_string(),
836 content: format!("# Requirements for {}\n\nTest requirements.", ctx.spec_id),
837 artifact_type: crate::status::artifact::ArtifactType::Markdown,
838 blake3_hash: String::new(), };
840
841 Ok(crate::phase::PhaseResult {
842 artifacts: vec![artifact],
843 next_step: NextStep::Continue,
844 metadata: crate::phase::PhaseMetadata {
845 packet_hash: None,
846 budget_used: None,
847 duration_ms: None,
848 },
849 })
850 }
851 }
852
853 let phase = EvidencePhase;
854 let config = OrchestratorConfig {
855 dry_run: true,
856 config: HashMap::new(),
857 full_config: None,
858 selectors: None,
859 strict_validation: false,
860 redactor: std::sync::Arc::new(crate::redaction::SecretRedactor::default()),
861 hooks: None,
862 };
863
864 let result = orchestrator.execute_phase(&phase, &config).await;
866
867 assert!(result.is_ok(), "Phase execution should succeed");
868 let exec_result = result.unwrap();
869 assert!(exec_result.success, "Execution should succeed");
870
871 let receipt_path = exec_result.receipt_path.expect("Receipt path should exist");
873 let receipt_content = std::fs::read_to_string(&receipt_path).expect("Should read receipt");
874 let receipt: serde_json::Value =
875 serde_json::from_str(&receipt_content).expect("Should parse receipt");
876
877 let packet = &receipt["packet"];
879 assert!(packet.is_object(), "packet field should exist");
880
881 let files = &packet["files"];
882 assert!(files.is_array(), "files should be an array");
883 assert_eq!(files.as_array().unwrap().len(), 2, "Should have 2 files");
884
885 let first_file = &files[0];
887 assert_eq!(first_file["path"], "src/main.rs");
888 assert_eq!(first_file["range"], "L1-L100");
889 assert_eq!(first_file["blake3_pre_redaction"], "abc123");
890
891 let second_file = &files[1];
893 assert_eq!(second_file["path"], "Cargo.toml");
894 assert_eq!(second_file["range"], "L1-L50");
895 assert_eq!(second_file["blake3_pre_redaction"], "def456");
896 }
897}